An Effect-first TypeScript diagnostic formatter for Neovim. It takes those walls of Effect<A, E, R> assignability errors and turns them into something you can actually read, with dedicated handling for the Effect ecosystem's usual pain points.
Regular TypeScript errors get the same treatment too, but Effect is the headline act.
When you're learning Effect, half the type errors look like this:
Type 'Effect<void, DbError, Database | Logger>' is not assignable to type 'Effect<void, DbError, never>'.
VSCode and Zed render that as one wrapped line in a tooltip. The part you actually care about ("you forgot to provide Database | Logger") is buried in there somewhere.
This plugin parses the error, figures out which channel (A, E, or R) actually diverged, and renders this instead:
╭─ ◈ Effect — Missing Services
│
│ ◈ Forgot to provide: Database | Logger
│ ⚡ Hint: .pipe(Effect.provide(SomeLayer))
│
│ Got: Effect<void, DbError, Database | Logger>
│ Expected: Effect<void, DbError, never>
╰─
Here are more scenarios the plugin recognizes, shown as they render in the float.
╭─ ⚠ Effect — Unhandled Errors
│
│ ⚠ Not in E channel: NetworkError | ParseError
│ ⚡ Hint: .pipe(Effect.catchTags({...})) or Effect.orDie
│
│ Got: Effect<User, NetworkError | ParseError, Http>
│ Expected: Effect<User, never, Http>
╰─
╭─ ⊘ Effect — A Mismatch
│
│ ✗ Got A: User
│ ✓ Expected: string
│
│ Got: Effect<User, NetworkError | ParseError, Http>
│ Expected: Effect<string, NetworkError | ParseError, Http>
╰─
Scope gets detected on its own, so the hint suggests Effect.scoped instead of a generic Effect.provide.
╭─ ◈ Effect — Scope Required
│
│ ◈ Forgot to provide: Scope
│ ⚡ Hint: wrap in Effect.scoped(...) — Scope is required
│
│ Got: Effect<string, never, Scope>
│ Expected: Effect<string, never, never>
╰─
When two or more channels diverge at once, the compact boxes step aside and you get a full tri-channel view with all the annotations stacked at the bottom.
╭─ ⊘ Effect Mismatch
│
│ ✗ Got:
│ A: void
│ E: NetworkError | ParseError | DbError
│ R: Http | Database | Logger
│
│ ✓ Expected:
│ A: void
│ E: never
│ R: never
│
│ ◈ Forgot to provide: Http | Database | Logger
│ ⚠ Unhandled errors: NetworkError | ParseError | DbError
╰─
Layer mismatches get their own channel labels (ROut / E / RIn) and a Layer.provide / Layer.merge hint.
╭─ ⊘ Layer — ROut Mismatch
│
│ ✗ Got ROut: Database | Http
│ ✓ Expected: Database
│
│ Got: Layer<Database | Http, never, never>
│ Expected: Layer<Database, never, never>
╰─
Stream follows the same shape as Effect, but the header is relabeled so you can tell at a glance which constructor you're dealing with.
╭─ ◈ Stream — Missing Services
│
│ ◈ Forgot to provide: Database
│ ⚡ Hint: .pipe(Effect.provide(SomeLayer))
│
│ Got: Stream<User, NetworkError, Database>
│ Expected: Stream<User, NetworkError, never>
╰─
Everyday TS errors get the same treatment, not just the Effect ones. A few representative cases:
Missing property (TS2741). Long object types wrap at ; boundaries so the shape stays scannable.
╭─ ◈ Missing Property
│
│ ◈ Property: 'preferences'
│ ◇ In: { id: string;
│ email: string;
│ profile: { name: string; age: number; country: string; };
│ }
│ ◆ Required: User
╰─
Unknown property (TS2339 / TS2551). The property name gets lifted out of the raw message so the typo jumps right at you.
╭─ ❓ Unknown Property
│
│ ✗ 'roles' not found
│ ◇ on type: Session
╰─
Type mismatch (TS2322 / TS2345). The classic Got/Expected diff, with unions shown inline.
╭─ ⊘ Type Mismatch
│
│ ✗ Got: "verbose"
│ ✓ Expected: "debug" | "info" | "warn" | "error"
╰─
Effect-specific
Effect<A, E, R>/Effect.Effect<A, E, R>/Stream<A, E, R>/Layer<ROut, E, RIn>are all parsed, channel-labeled, and diffed- Missing services (forgot
Effect.provide): a compact box naming the exact services - Unhandled errors (forgot
Effect.catchAll/catchTags/orDie): a compact box listing the leftoverEmembers - Wrong success type: a compact box with Got/Expected for
A(orROutfor Layer) - Scope-required: detects
Scopein got.R and suggestsEffect.scoped - Uninferred channels:
unknown/anyinRorEis not a service you can provide, so it gets its own box pointing at the widening rather than atEffect.provide Context.Tag<"Id", Service>unwrapping: diffs show just the service nameYieldWrap<...>unwrapping: Effect.gen yields render as plain Effect mismatches- Short signatures:
Effect<A>andEffect<A, E>default missing params tonever - Multi-channel view: falls back to a full tri-channel table when 2+ channels diverge
- Identical signatures: when both sides normalize to the same type, says so (and points at duplicate copies of a package) instead of printing a Got/Expected diff of a type against itself
- Type signature at the bottom of every compact box so you always see the full
Effect<...>context @effect/language-servicediagnostics (sourceeffect):missingEffectContext,missingEffectErrorand the Layer-context report reach the same boxes, without any types to diff
General TypeScript
- Type mismatches (TS2322 / TS2345) with multi-line wrapping for big types
- Missing property (TS2741), Unknown property (TS2339)
- Cannot find name / module / exported member (TS2304 / TS2307 / TS2305)
- Implicit any (TS7006), Uninitialized variable (TS2454), Nullish (TS2531 / TS18048)
- Argument count (TS2554 / TS2555, including the
at least NandN-Mforms), Const reassignment, Not callable (TS2349) - Overload errors (TS2769): the headline names no types, so the overload report beneath it is parsed instead — an
Effectone in preference to a plain one - Deprecated symbol messages
- Optional fallback to
format-ts-errors.nvimwhen no pattern matches
Requires Neovim 0.10+.
{
"rashedInt32/effect-error-pretty.nvim",
version = "*", -- pin to tagged releases; drop this to track main
event = { "BufReadPre", "BufNewFile" },
opts = {
float = true, -- auto-patch vim.diagnostic.config.float.format
effect = true,
format_ts_errors_fallback = true,
},
}See the changelog for what's in each release.
If you manage vim.diagnostic.config yourself, don't set float = true. Call the formatter directly:
local pretty = require("effect-error-pretty")
pretty.setup({ effect = true })
vim.diagnostic.config({
float = {
format = function(diagnostic)
return pretty.float_format(diagnostic) or diagnostic.message
end,
},
}){
"rachartier/tiny-inline-diagnostic.nvim",
opts = {
options = {
format = function(diagnostic)
return require("effect-error-pretty").inline_format(diagnostic) or diagnostic.message
end,
},
},
}If you want to see the plugin wired up end-to-end (float format, sign icons, spotlight highlights, and the tiny-inline-diagnostic integration), take a look at the LazyVim config it was developed against:
https://github.com/rashedInt32/lazyvim-config
The boxes are multi-line, and vim.diagnostic was built for one-line messages. Two float options will happily splice text onto a box and knock it off its own gutter — neither is something this plugin can defend against, so they're worth knowing.
source = true shifts the box's first line. open_float runs your format function first and only then prepends the source, so the source lands on line 1 of the finished box and nothing else:
ts: ╭─ ⚠ Effect — Unhandled Errors ← pushed right by #"ts: "
│ ← the gutter below it is not
Set source = false and add it back yourself for the messages that aren't boxed:
format = function(diagnostic)
local rendered = require("effect-error-pretty").float_format(diagnostic)
if rendered then
return rendered -- a box speaks for itself
end
local src = diagnostic.source and (diagnostic.source .. ": ") or ""
return src .. diagnostic.message
end,suffix lands after the closing ╰─. Same cause, other end. Suppress it when the message is already a box — note that by the time suffix runs, format has replaced diagnostic.message with the rendered box, so check that rather than re-running the formatter:
suffix = function(diagnostic)
if diagnostic.message:sub(1, #"╭") == "╭" then
return "", ""
end
return string.format(" [%s]", diagnostic.code or ""), "Comment"
end,Opts passed to open_float beat your config. If a keymap does vim.diagnostic.open_float(nil, { source = "always" }), that wins over vim.diagnostic.config({ float = { source = false } }) and the prefix comes back — no matter how carefully the global config is set. Worth grepping for if a box looks misaligned even after the above.
| Option | Default | Description |
|---|---|---|
effect |
true |
Recognize Effect / Stream / Layer mismatches as first-class. |
float |
false |
Automatically patch vim.diagnostic.config.float.format on setup(). |
width |
70 |
Target box width in display cells. Capped to the current window at render time: Neovim wraps the float at the window width, and that wrap carries no gutter. |
sources |
{ typescript=true, ts=true, vtsls=true, effect=true } |
Diagnostic sources this plugin handles. Exact match only. Merged with the defaults, so { deno = true } adds Deno — pass { typescript = false } to drop one. effect is @effect/language-service. |
format_ts_errors_fallback |
true |
When no pattern matches, try format-ts-errors.nvim if installed. |
extra_patterns |
nil |
List of function(msg) -> {kind, ...} parsers run after builtins. Let you add custom shapes. |
typesafe |
removed | Jev enrichment moved to jury.nvim. The option is ignored with a warning. See below. |
If float = true and something else later calls vim.diagnostic.config({ float = ... }), Neovim replaces the whole float table — the formatter reinstalls itself on LspAttach, so the ordering doesn't matter.
Every ⚡ Hint: line above is generic: .pipe(Effect.provide(SomeLayer)),
.pipe(Effect.catchTags({...})) or Effect.orDie. The parser knows the family
exactly, but not which layer you meant or which fix fits this function.
jury.nvim fills that in. It asks Jev to pick among the layers and error classes found in your workspace and among a fixed set of fix shapes, then hands the pick back here to render through this plugin's own templates:
│ ◈ Forgot to provide: Greeter | Database
│ ⚡ Jev: .pipe(Effect.provide(AppLive))
│ ↳ this expression is the program boundary · 0.99
A Layer missing its own RIn gets Layer.provide(X) inside this layer.
A channel that is only unknown has no service to name, so the "annotate
the effect to find where R widened" line becomes the place itself:
│ ⚡ Jev: annotate steps (src/jobs.ts:145), where R widened
It also replaces the TS2769 overload heuristic when Jev is confident about which nested report you meant.
Install both and it works. Install only this plugin and nothing changes:
{ "rashedInt32/effect-error-pretty.nvim", opts = { float = true } },
{ "rashedInt32/jury.nvim", dependencies = { "rashedInt32/effect-error-pretty.nvim" }, opts = {} },Three things to know.
- Nothing blocks. jury judges in the background when diagnostics change and caches by message plus enclosing function. The formatter only reads that cache. A miss renders the generic hint; the next hover is concrete.
- Code still owns the candidates. Jev selects a layer, an error class, a fix shape, or a report index from lists this plugin or jury enumerated. It never writes code or renames a type.
- This plugin stays offline on its own. The seam is two registration functions, below. No key, no network, no option to set here.
local pretty = require("effect-error-pretty")
-- Float box (multi-line). Returns nil if unhandled; caller should fall back.
pretty.float_format(diagnostic)
-- Inline one-liner. Returns nil if unhandled.
pretty.inline_format(diagnostic)
-- Low-level: parse a raw TS diagnostic message into a structured kind.
-- Returns nil if no pattern matched.
require("effect-error-pretty.parse").parse(message, { effect = true })
-- Seams for an external judge (this is what jury.nvim registers into).
-- The resolver runs inside vim.diagnostic's formatter: synchronous, fast.
pretty.set_hint_resolver(function(parsed, family, names, diagnostic)
-- family is "services", "errors" or "widened"; return nil for the generic
-- hint, or { label = "Jev", line = "...", detail = "..." } to replace it,
-- or { lean = "..." } to keep it and add a line underneath.
-- "services" with parsed.tag == "layer" is a Layer's missing RIn.
-- "widened": names[1] is the channel label (R, RIn or E) that is only
-- unknown/any; the job is to say which definition lost its type.
end)
pretty.set_overload_picker(function(msg, candidates) return index_or_nil end)
pretty.hint_family(parsed) -- "services"|"errors"|"widened"|nil, names
pretty.templates.provide(layer, names, where)
pretty.templates.unhandled(fix, names, target)
pretty.templates.widened(label, name, file, line)Want to catch a custom rule? Add your own parser:
require("effect-error-pretty").setup({
extra_patterns = {
function(msg)
local rule = msg:match("^eslint%-plugin%-effect: (.+)$")
if rule then
return { kind = "custom_lint", rule = rule }
end
end,
},
})Render logic for custom kinds isn't wired in yet. For now, parse() returns the structured result and you render it yourself. If there's demand, we'll expose a renderer registry.
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }" -c "qa!"PlenaryBustedDirectory spawns one child Neovim per spec file, and those children only pick up minimal_init.lua if it's passed in the command — without it they load your personal config instead.
Requires plenary.nvim. tests/minimal_init.lua finds it under stdpath("data"); set PLENARY_PATH if yours lives elsewhere.
MIT






