Skip to content

Rule proposal: T | null return types are Option in disguise #679

Description

@suddenlyGiovanni

Problem

In plain TypeScript, T | null is the correct way to model absence in a return type. The missing case is a value, and it is part of the signature. The native alternatives are worse: a throw does not appear in the signature, and a sentinel like -1 or "" looks like data. With strictNullChecks, the compiler forces every caller to handle the null case. At this level there is nothing to fix. This is as far as the base language can model absence.

A codebase that runs these diagnostics has effect installed by definition. So a better encoding of the same contract is available: Option<T>, with the absence case as a named value. The rule suggests that lift. The gain is concrete. Callers compose with map/getOrElse/match instead of writing a null guard at every call site. And the contract nests: (T | null) | null collapses into one case, while Option<Option<T>> keeps "no result" and "stored null" apart. Option is plain data and does not need the Effect runtime, so the suggestion holds for a codebase mid-migration, not only at the end.

The Effect-native category ("prefer Effect-native APIs and abstractions when available") already covers the async axis with asyncFunction and newPromise. Nothing covers the absence axis. This rule fills that gap and owns only that axis: it is not scoped to synchronous functions, so (): Promise<User | null> fires the same way, toward Promise<Option<User>>. Async control flow stays asyncFunction's concern. The two rules compose, and both can fire on the same signature with independent fixes.

The signature-level scope also makes the diagnostic a steering signal for coding agents. A nullable contract teaches an agent to write a defensive null guard at every call site it generates. This diagnostic points at the contract instead. An agent reads it from typecheck or lint output in its own loop, at the severity the adopter sets. After the contract changes, the compiler reports each call site that must follow.

Bad — compiles cleanly, the rule should flag this

// RULE: nullableReturnToOption
// BAD: absence modeled as a value, with the tools the base language has.
// This is correct native TypeScript, and it is the contract Option
// represents first-class. Each shape below authors it at the signature
// level: annotated, inferred, async, or in a type position.
type User = { readonly name: string }
declare const registry: Map<string, User>

// annotated
export function findUser(id: string): User | null {
  return registry.get(id) ?? null
}

// inferred — no annotation, same contract: the return type resolves to User | null
export function findUserLoose(id: string) {
  return registry.get(id) ?? null
}

// async — fires identically; async-ness is asyncFunction's axis, not this rule's
export async function fetchUser(id: string): Promise<User | null> {
  return registry.get(id) ?? null
}

// type position — the contract every implementation will inherit
export interface UserRepo {
  find(id: string): User | null
}

Good

// RULE: nullableReturnToOption
// GOOD: the same contract, lifted. The absence case is a named value.
// Callers compose with map/getOrElse/match instead of writing a guard,
// and Option<Option<A>> nests where (A | null) | null collapses.
import { Option } from "effect"

type User = { readonly name: string }
declare const registry: Map<string, User>

export function findUser(id: string): Option.Option<User> {
  const user = registry.get(id)
  return user === undefined ? Option.none() : Option.some(user)
}

export async function fetchUser(id: string): Promise<Option.Option<User>> {
  const user = registry.get(id)
  return user === undefined ? Option.none() : Option.some(user)
}

export interface UserRepo {
  find(id: string): Option.Option<User>
}

Proposed rule behavior

  • Flag every signature declaration in linted source, exported or not. Value positions: function, method, and accessor declarations, plus function/arrow expressions that initialize a binding or a property. Type positions: interface members, call signatures, and function-typed type aliases. The trigger: the resolved return type, after unwrapping one level of Promise<…>, is a union that contains null plus at least one non-null member. Annotated and inferred returns spell the same contract; the checker resolves both. Report on the return type annotation when present, otherwise on the signature's name.
  • The diagnostic sits where the null is authored, once. An implementation or override of a member declared elsewhere stays silent. A function expression whose return type is contextually imposed stays silent too. Fix the authored contract (UserRepo.find) and the compiler drives every conformer, so one interface with five implementations yields one diagnostic, not six. Contracts from node_modules or ambient .d.ts are never linted, so conforming to a vendor signature that demands string | null produces no noise. The same rule silences components typed React.FC<Props>, with no framework-specific logic.
  • Stay silent on function/arrow expressions in argument position. An inline callback is not a durable named contract. When it graduates to a named declaration, the first bullet catches it.
  • Top-level union only. Nullability nested inside the returned shape ((): { user: User | null }) is not the return contract; stay silent.
  • Uniform by design, no edge-case carve-outs in v1. void | null and Option<User> | null fire like any other qualifying union (for the latter, the honest contract is Option<User> itself, not a double wrap — an edge for the implementation to settle). A bare JSX component typed (): Element | null fires too. That is accepted v1 noise: React's render-nothing idiom is real, but teaching a generic rule library-specific types is worse. Real usage should inform any later narrowing.
  • Diagnostic-only, no codefix. The rewrite is a contract change, and its value is that the compiler reports every call site that must follow; a local autofix would hide that. Interop with a nullable producer stays one call: Option.fromNullable (v3) / Option.fromNullishOr (v4).
  • Out of scope: | undefined return contracts. undefined appears incidentally — Map.get wrappers, optional passthroughs — at a rate that would drown an opt-in diagnostic. A sibling rule of identical shape can own that axis; coverage then composes by enabling rules, not by configuring options. Per-path applicability (for example, relaxing over test globs) is the linter's own scoping capability, not part of the rule.

Supersedes #252 — re-scoped to one rule per issue in the current proposal format; the synchronous-throw sibling will follow separately.

Proposed rule name

Left open on purpose. The examples above use the first candidate as a placeholder; final pick per the implementer's naming convention:

  • nullableReturnToOption — the <pattern>To<api> grammar of the recent proposals
  • nullableReturn — the construct-named grammar of the Effect-native category-mates (asyncFunction, newPromise)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions