Every LLM writes key={index} on list items. It's in millions of React tutorials as the quick fix — React wants a key, here is a key. The code compiles. It renders. When the list reorders or an item is removed from the middle, React reuses the wrong DOM nodes: state stays pinned to the old position, controlled inputs keep stale values, transitions fire on the wrong elements.
A lint rule fixes this. react/no-array-index-key fires: "Do not use Array index in keys — use a stable identifier." The agent switches to item.id. That class of diffing bug is extinct — not "less likely because the system prompt mentioned it."
The check step
Agents work in a loop: write, check, fix, repeat. A slow, generic check — "build failed" — means the agent wastes tokens re-reading output and guessing at the cause. A fast, specific check — "line 42: expected Effect<void, ConfigError> but got string" — means the agent fixes it in the same turn.
Agent-native tools — opencode, Cursor — surface LSP diagnostics inline:
src/agent.ts:42:5: error TS2345: Argument of type 'string' is not
assignable to parameter of type 'Effect<void, ConfigError, Config>'
The error arrives with the file state. No log parsing. Wired into the toolchain, not into the prompt.
TypeScript strict
strict: true is table stakes. Most codebases stop there — and so does the agent's training data.
The real leverage is in the flags strict: true doesn't cover. noUncheckedIndexedAccess makes array indexing return T | undefined instead of T, so the agent can't write users[0].name without handling the missing case. exactOptionalPropertyTypes distinguishes "may be absent" from "may be undefined" — a difference the agent's training data almost certainly conflates. noPropertyAccessFromIndexSignature forces bracket notation on dynamic keys.
The agent's prior says users[0].name is fine. The type checker disagrees.
Language service plugins
TypeScript strict catches structural errors. Your domain has its own.
The tsconfig.json includes @effect/language-service as a compiler plugin. floatingEffect catches Effects that aren't yielded or assigned — silent no-ops that type-check fine. missingEffectContext flags missing service requirements. effectGenUsesAdapter detects the v3 adapter pattern — the same failure, caught at a different layer. outdatedApi flags removed and renamed APIs across the v3-to-v4 migration. missingStarInYieldEffectGen catches yield effect where you need yield* effect — a mistake agents make constantly because both type-check.
This isn't niche. ts-graphql-plugin type-checks GraphQL queries against your schema. @styled/typescript-styled-plugin catches bad CSS properties in styled-components. @css-modules-kit/ts-plugin type-checks CSS Modules imports. One line in tsconfig.json, domain-level diagnostics through the LSP channel the agent already reads.
The Effect plugin goes further: effect-language-service patch patches tsc to surface these diagnostics at build time. The agent running tsc --noEmit gets Effect-specific errors alongside standard TS errors. There's an includeSuggestionsInTsc option that surfaces suggestion-level diagnostics in tsc output with a [suggestion] prefix. The plugin's docs say it explicitly: "useful to help steer LLM output." The authors are already thinking about agents.
The linter
The examples here use Biome — much faster than ESLint for the same codebase. When linting is in the agent's inner loop, that difference compounds. But the specific tool matters less than two properties: it runs fast, and it supports custom rules.
Configuration matters more than the tool. noUnusedImports: "error". noDoubleEquals: "error". useConst: "error". noExplicitAny: "warn". Everything else: error. The agent can't leave dead imports or loose equality checks. A warning is a suggestion; the agent might fix it, might not. An error is a gate.
Custom rules
The layers so far catch structural errors, domain type errors, and pattern violations. They don't catch this: the agent writes code that type-checks, passes lint, and is semantically wrong for your project.
The most common cause is training data staleness. The model learned v3 of an API. Your codebase uses v4. The v3-to-v4 migration renamed and restructured dozens of APIs — but the v3 patterns are still valid TypeScript in many cases. The types didn't change enough to break them — but they're not what you want. You can put this in a system prompt: "Always use v4 patterns for Effect." The agent will follow it — until context pressure pushes the instruction out of the effective window, or the model's prior on this particular API is strong enough to override.
System prompt instructions are probabilistic. They work most of the time.
Lint rules are deterministic. They fire every time the pattern appears. They don't get overridden by training priors. And they're faster to write than you'd expect.
Any linter with a custom rule system works. The examples below use GritQL — a pattern-matching language for source code, used by Biome for its plugin system. You write a pattern, it matches against the AST, and you register a diagnostic. ESLint achieves the same thing with AST visitor functions. Here's the rule that killed the v3 adapter pattern:
language js(typescript)
`Effect.gen(function*($adapter) { $body })` where {
$adapter <: r"^\w+$",
register_diagnostic(
span=$adapter,
message="Effect v4: remove the adapter parameter. Use `yield* effect`
directly instead of `yield* adapter(effect)`.
Load skill: effect-v4.",
severity="error"
)
}
Four of these rules exist so far. Each one started as a failure that showed up twice:
// Layer.succeed(Tag, impl) → curried Layer.succeed(Tag)(impl)
`$fn($tag, $impl)` where {
$fn <: or { `Layer.succeed`, `Layer.effect`, `Layer.scoped` },
register_diagnostic(span=$fn,
message="Effect v4: Layer constructors are curried.
Use Layer.succeed(Tag)(impl) instead of Layer.succeed(Tag, impl).
Load skill: effect-v4.",
severity="error")
}
// @effect/schema → import from "effect"
`$source` where {
$source <: `"@effect/schema"`,
register_diagnostic(span=$source,
message="Effect v4: @effect/schema is gone.
Import from 'effect' instead: import { Schema } from 'effect'.
Load skill: effect-v4.",
severity="error")
}
// Effect.catchAll → Effect.catch (renamed in v4)
`$fn($args)` where {
$fn <: `Effect.catchAll`,
register_diagnostic(span=$fn,
message="Effect v4: catchAll was renamed to Effect.catch.
See: https://github.com/Effect-TS/effect-smol/blob/main/migration/error-handling.md
Load skill: effect-v4.",
severity="error")
}
The operational loop: agent produces a v3 pattern → the pattern becomes a lint rule five minutes later → the next check catches it from that point forward.
Error messages are prompts
The error message in a lint rule is a prompt — it fires at the exact moment the pattern appears, with the exact fix included, and no competition with the rest of the context window.
"Invalid pattern" wastes the agent's tokens on diagnosis. "Effect v4: remove the adapter parameter. Use yield* effect directly" gives the agent a direct edit target.
The rules above go further — each message ends with a skill-load directive. One bad Layer.succeed call suggests the agent's mental model of Effect v4 is stale across the board. "Load skill: effect-v4" points it at a reference that covers constructors, generators, error handling — everything adjacent to the specific mistake. The error message fixes the immediate line; the skill load fixes the next twenty.
Every failure that shows up twice becomes a rule. The rules accumulate. The codebase gets stricter — not through documentation that ages or review knowledge that walks out the door, but through tooling that fires on every check.
Top comments (0)