Skip to content

Proposal: synchronous-partiality diagnostics — preferOptionOverNullable & preferEitherOverThrow (completing the Effect-native category) #252

Description

@suddenlyGiovanni

Process note — I'm at the helm of this proposal: the problem, the trade-offs, and the scoping decisions are mine. I used an AI assistant to pressure-test the design, refine the wording, and shape it into a clear feature-request format. I'd rather say that openly than have the polish read as autogenerated — the articulation is assisted, the thinking is human. That division (human in command, AI as a sharp tool) is the very thing this proposal argues for.

TL;DR — The Effect-native diagnostics already steer asynchronous partiality toward Effect (asyncFunction, newPromise), but there is no equivalent for synchronous partiality. This proposes two off-by-default diagnostics that complete the axis: a null/undefined return → suggest Option, and a synchronous throw → suggest Either. Diagnostic-only to start (no autofix), exactly how asyncFunction ships today. The payoff is human DX and — increasingly the bigger story — a machine-readable steering signal that stops LLM coding agents from generating defensive boilerplate at every call site.

The problem

A function with a dishonest contract pushes work onto every caller:

  • function find(id): User | null — every consumer writes if (user === null). The contract says absence is possible but not what it means (not found? error? both?), so each caller re-invents the guard.
  • function parse(raw): Config { /* … */ throw new Error("bad input") } — the failure is invisible: it never appears in the type. Callers don't know to handle it until it throws at runtime.

Both are the same disease — partiality (the function isn't total) leaking out as a nullable union or an untyped throw, instead of being modeled as a value. Effect already has the honest forms: Option for absence, Either for synchronous failure. Nothing currently nudges plain-TS code toward them.

The gap: a hole in the Effect-native category

The existing Effect-native diagnostics encode "plain-TS construct → prefer the Effect counterpart." On the partiality axis they cover async but not sync:

partiality shape already covered proposed
async / Promise control flow asyncFunction, newPromise
synchronous throw (failure with info) preferEitherOverThrowEither
null / undefined return (absence, no info) preferOptionOverNullableOption

The right target depends on what the function is — Effect has a deliberate three-rung ladder:

rung when function coloring runtime cost
Option<A> absence, no error info colorless ~free (tagged union)
Either<A, E> synchronous failure, carries info colorless ~free (tagged union)
Effect<A, E, R> failure + async / resources / deps viral interpreted (fiber runtime)
flowchart TD
    fn([Function may not return a plain value]) --> q1{"Signals absence,<br/>no error info?"}
    q1 -->|yes| opt["Option&lt;A&gt;<br/>colorless · ~free"]
    q1 -->|"no: fails with info"| q2{"Synchronous?<br/>no async / resources / deps"}
    q2 -->|yes| either["Either&lt;A, E&gt;<br/>colorless · ~free"]
    q2 -->|no| eff["Effect&lt;A, E, R&gt;<br/>viral color · interpreted"]
    opt -. proposed .-> ruleA[["preferOptionOverNullable"]]
    either -. proposed .-> ruleB[["preferEitherOverThrow"]]
    eff -. already exists .-> ruleC[["asyncFunction"]]
Loading

The boundary with asyncFunction is deliberate and keeps the rules from overlapping: asynchronous failure is already Effect's job — these two rules own only the synchronous rungs.

The two rules

  • preferEitherOverThrow — a synchronous function that can throw is the value-level twin of Either's Left. Suggest returning Either<A, E> so the failure becomes a value the compiler tracks. (Async-that-throws stays with asyncFunctionEffect.)
  • preferOptionOverNullable — a function whose return type includes null is signaling absence. Suggest Option<A>, making "no value" a first-class case instead of a sentinel every caller must remember to check.

Both default to off (see Severity) and ship diagnostic-only first — exactly how asyncFunction and newPromise already work.

Why the suggestion carries a matrix, not a bare verdict

"Why not just suggest Effect for everything?" — because that over-prescribes, and the reason it over-prescribes is the thing both humans and agents need in the message. Two axes justify staying on the lowest honest rung.

Function coloring (Nystrom, What Color is Your Function?, 2015). A throw is an invisible color — an exceptional channel that propagates but never shows in the type. Option/Either are colorless: callable anywhere, pattern-matched inline, no runtime. Effect introduces a visible, viral color — to consume it you must be in an Effect context or call run* at a boundary, and it spreads up the call graph exactly like async. For a function whose only sin is a synchronous throw, Either removes the invisible color without painting on a viral one.

Diagram — how the color spreads
flowchart LR
    subgraph S1["Either / Option — colorless"]
        direction TB
        a1[caller] --> leaf["leaf returns Either&lt;A,E&gt;"]
        a2[caller] --> leaf
    end
    subgraph S2["Effect — color propagates upward"]
        direction TB
        b1["caller: forced into Effect"] --> b2["caller: forced into Effect"] --> bleaf["leaf returns Effect&lt;A,E,R&gt;"]
    end
    class b1,b2,bleaf colored
    classDef colored fill:#ffd9d9,stroke:#cc3333,color:#000
Loading

Runtime cost. Either is a tagged union — construct + tag-check. Effect is interpreted: even synchronous execution steps through the fiber run-loop. Negligible at IO boundaries; real on hot synchronous paths — e.g. a wire-format decoder invoked once per network packet. Suggesting Effect there silently regresses the hot loop.

So the proposal is verdict-led, matrix-justified: the diagnostic names the rung that matches the detected shape (sync-throw → Either, nullable → Option), then surfaces the ladder above as the why, and when to choose otherwise. A reader — human or agent — can then override with reason (escalate to Effect when the function genuinely needs a dependency; stay on Either precisely because it's hot). A preferEffectOverEither option exists for codebases that deliberately want one target everywhere.

More than a linter: generation-steering for coding agents

We think this is becoming one of the most valuable things Effect's tooling can do, and it is the part most worth scrutinising.

LLM coding harnesses share one mechanism: your codebase is the few-shot example set the model generates from (a point Michael Arnaldi has made repeatedly about using AI agents with Effect; Karpathy's "models are weak at niche code" is the same coin's other face — local context is what compensates). So a dishonest contract doesn't only cost human readers — it teaches the model to emit more of the same:

  • For T | null, an agent pattern-matches the nullable signature and generates more defensive if (x === null) guards at every new call site.
  • For throw, it generates none — nothing in the type signals the need — and the code crashes at runtime.

Either way the dishonest contract propagates at the scale of generated code, with no machine signal steering toward the honest type. These diagnostics are that signal. End to end:

flowchart TD
    d["Diagnostic at the definition<br/>nullable return · or · sync throw"] --> r["Agent reads it<br/>via typecheck output, in its edit→check loop"]
    r --> f["Agent fixes the definition<br/>→ Option / Either"]
    f --> o["TypeScript surfaces typed obligations<br/>at every call site"]
    o --> m["Agent discharges each with<br/>Option.match / Either.match,<br/>not ad-hoc null guards"]
    m --> h([Honest contract,<br/>aligned consumers])
    o -. "each obligation re-enters the loop" .-> r
Loading

The pivotal move: the local fix at the definition makes the contract honest, and TypeScript then re-surfaces the previously-invisible obligations as typed errors at the call sites — which an agent discharges with Option.match / Either.match in its normal edit→typecheck loop, instead of inventing ad-hoc guards. The breakage is the steering. A linter that does this is an active participant in code generation, not a passive checker — and that is squarely the "Effect makes agents productive" story the ecosystem is already telling.

On the delivery channel (honest caveat). This steering only works through a channel the agent actually consumes. The robust, harness-agnostic one is the typecheck CLI output — suggestion-severity diagnostics print without failing the exit code, so any agent that runs a typecheck in its loop reads them after every edit. The editor/LSP channel additionally serves humans, but note that some agent harnesses are push-only LSP clients that never issue textDocument/diagnostic pull requests, so they will not surface editor-style Hints. That is a harness-side limitation, not a language-service one — and the CLI channel routes around it entirely.


Full trigger specification

preferOptionOverNullable

Fires when all hold:

  • The function's resolved return type — annotated or inferred — is a union that includes null. (undefined is opt-in via checkUndefinedReturns; it appears incidentally far more often, so it is off by default.)
  • There is a meaningful success type T: the type is T | null where T is not never / void / undefined-alone. A pure void/undefined-returning function is "no result," not "absence of a T."
  • The function does not already return Option / Either / Effect.

Larger unions are fine: A | B | nullOption<A | B>.

preferEitherOverThrow

Fires when all hold:

  • The function is synchronous — not async, and its return type is not Promise / PromiseLike. (This is the hard boundary with asyncFunction: async-that-throws is its domain → Effect.)
  • It contains ≥1 throw lexically in its own body — nested function/arrow scopes are separate functions, analysed on their own.
  • The throw can escape — it is not enclosed by a try whose catch is in the same function (locally-caught throws are internal control flow, not part of the failure contract).
  • Direct throws only. Transitive throws (a callee that throws) need interprocedural analysis TypeScript cannot do reliably without checked exceptions → explicit non-goal (see Scope).
  • Fires regardless of the thrown value type (Error, subclass, or throw "literal").

Defect caveat, not auto-classification. v1 does not try to syntactically distinguish a domain failure from a defect (throw new Error("unreachable")). It fires, but the message notes: if this is a defect / impossible-state rather than an expected failure, prefer leaving it (or Effect.die) — Either is for expected failures. (Assertion-signature functions are hard-excluded — see Exclusions.)

Exclusions & scope

Shared (both rules):

  • Already-monadic returns — function already returns Option / Either / Effect.
  • .d.ts / ambient declarations — no body to analyse; usually describes APIs you don't own.
  • Test filesthrow and nullable fixtures are normal in tests; excluded by a configurable glob.

preferOptionOverNullable only:

  • React/JSX components — a component returning JSX.Element | null / ReactNode is idiomatic "render nothing." Hard-exclude functions whose return type is assignable to ReactNode / ReactElement / JSX.Element. (This is the highest-frequency false positive.)

preferEitherOverThrow only:

  • Assertion signatures (asserts x is T / asserts x) and type-predicate guards — their job is to throw/narrow; typing them as Either defeats the purpose.
  • constructor methods — cannot return Either. The honest remedy is a smart constructor / factory, which is a structural refactor, not a return-type change → excluded in v1, noted as a future extension.
  • ignoredThrowingFunctions: string[] — config allowlist for untyped invariant() / assert() helpers the asserts-signature exclusion won't catch.

Scope — exported/public by default. The whole point is the consumer boundary (defensive code lives where a dishonest contract crosses a module edge). An internal helper used once in-file creates no consumer sprawl, so by default the rules fire on exported functions only. includeInternal: true widens to all functions.

Severity & configuration

Default off — consistent with every opinionated "prefer-Effect-counterpart" rule in the Effect-native category (asyncFunction, newPromise, globalConsole, processEnv, …). Correctness rules earn error; opinions default to off. A codebase can legitimately use the language service purely for its correctness rules.

Recommended opt-in tier: suggestion — surfaces in editors and in typecheck CLI output without gating CI. Codebases that want the agent-steering set it deliberately.

Applies to both Effect v3 and v4Option and Either exist in both.

option rule default effect
preferEffectOverEither preferEitherOverThrow false suggest Effect instead of Either (Effect-maximalist codebases)
checkUndefinedReturns preferOptionOverNullable false also fire on undefined in the return union, not just null
includeInternal both false fire on non-exported functions too
ignoredThrowingFunctions preferEitherOverThrow [] function-name allowlist for invariant/assert-style helpers
Feasibility & done-ness roadmap (Gate A → B → C)

Gate A — diagnostic-only (this issue's ask). Both rules fire with the verdict + matrix message; no autofix.
Feasibility: high / already-proven. This is exactly what asyncFunction and newPromise are — Effect-native control-flow rules shipped diagnostic-only. Detection is in-engine: return-type inspection (channel-type inspection already exists for missingEffectError) and throw-statement AST walks (body-shape analysis already exists).

Gate B — local code action. A quickfix rewriting within the function's own boundary: return type → Option<T> / Either<A,E>; return nullOption.none(), return xOption.some(x), throw ereturn Either.left(e); auto-insert the import. The matrix becomes interactive — offer multiple actions ("Convert to Option / Either / Effect").
Feasibility: medium / precedented. Many rules ship 🔧 fixes doing exactly this class of local rewrite-plus-import. The hard part is semantic completeness: transform every return/throw path, or bail when control flow is too complex (loops, finally, nested branches).

Gate C — caller propagation. Rewriting call sites after the signature change.
Feasibility: low / deferred by design — explicit non-goal. Interprocedural, cross-file, and call sites are semantically diverse (null-check vs ?. vs ?? vs pass-through). That is a refactor-engine problem, not a quickfix. Leaving callers as typed errors (Gate B) is the intended, desirable behaviour — see the generation-steering section.

Prior art & references

Sibling rules (this project). asyncFunction and newPromise already steer async partiality toward Effect; preferSchemaOverJson and instanceOfSchema already steer toward a non-Effect ecosystem type (Schema) — establishing that steering toward Option/Either is in-mandate for the Effect-native category.

Concepts.

  • Function coloring — Bob Nystrom, What Color is Your Function? (2015).
  • Errors-as-values / "don't return null" — Scott Wlaschin, Railway Oriented Programming and Domain Modeling Made Functional. The "don't return null / Null Object" advice is long-standing; Option is its total, type-checked form.
  • Codebase-as-few-shot-context for agents — Michael Arnaldi's talks on using AI agents with Effect; Andrej Karpathy on LLMs and niche code (Dwarkesh Podcast, 2025).
  • TypeScript has no checked-exception information (microsoft/TypeScript#13219) — the reason transitive-throw detection is a non-goal.

Effect docs. Option · Either.

Scope of this issue

Asking for: Gate A (diagnostic-only) for both preferOptionOverNullable and preferEitherOverThrow; off by default; v3 + v4; verdict-led messages carrying the rung ladder.

Explicit non-goals (this issue):

  • Caller-site propagation / cross-file refactor (Gate C).
  • Transitive throw detection — direct/lexical throws only.
  • Auto-classifying defects vs domain failures — the message flags the distinction; it does not decide it.

Happy to split into two issues if you'd rather track the rules separately, or to move this to Effect-TS/language-service if that is the better home for rule semantics — the effect-tsgo-vs-language-service boundary looked in-flux from the outside.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions