Nullability & never
strictNullChecks: the setting that matters most
Section titled “strictNullChecks: the setting that matters most”The single most valuable compiler flag is strictNullChecks (part of strict). Without it, null and undefined are assignable to every type — the billion-dollar mistake, fully armed. With it, they are their own types you must handle explicitly.
// @expect-error TS2345// with strictNullChecks on:function len(s: string) { return s.length; }
let maybe: string | undefined;len(maybe); // ❌ Argument of type 'string | undefined' is not assignable to 'string'The error is the point: the compiler forces you to deal with the undefined case before you dereference. Since TypeScript 6.0, strict — and with it strictNullChecks — is on by default; a project has to opt out. If you inherit a codebase that set strictNullChecks: false, turning it back on is the highest-leverage migration you can do.
?. and ?? are type-aware
Section titled “?. and ?? are type-aware”Two operators make nullability ergonomic, and the type system understands both:
type User = { profile?: { avatar?: string } };
function avatar(u: User) { const url = u.profile?.avatar; // type: string | undefined return url ?? "default.png"; // type: string (?? supplied a fallback)}?. short-circuits to undefined if the left side is nullish, and the type reflects that (the result includes undefined). ?? supplies a fallback only for null/undefined (unlike ||, which also triggers on 0 and ""), and narrows the type by removing the nullish part.
|| vs ??: 0, "", and false are valid values too
Section titled “|| vs ??: 0, "", and false are valid values too”That parenthetical is worth its own section, because it’s one of the most common real bugs in codebases that predate ?? (added in TypeScript 3.7 / ES2020). || falls back on any falsy value — 0, "", false, NaN, null, undefined — not just the two “genuinely absent” ones:
interface ServerConfig { timeout?: number; label?: string; verbose?: boolean }
function withDefaults(cfg: ServerConfig) { const timeout = cfg.timeout || 30; // bug: 0 is a valid timeout, but falls back to 30 const label = cfg.label || "server"; // bug: "" is a valid label, but falls back const verbose = cfg.verbose || true; // bug: false is a valid choice, always becomes true return { timeout, label, verbose };}
console.log(withDefaults({ timeout: 0, label: "", verbose: false }));|| silently overrides every falsy-but-valid value
interface ServerConfig { timeout?: number; label?: string; verbose?: boolean }
function withDefaults(cfg: ServerConfig) { const timeout = cfg.timeout || 30; // bug: 0 is a valid timeout, but falls back to 30 const label = cfg.label || "server"; // bug: "" is a valid label, but falls back const verbose = cfg.verbose || true; // bug: false is a valid choice, always becomes true return { timeout, label, verbose };}
console.log(withDefaults({ timeout: 0, label: "", verbose: false })); Run it and every explicitly-chosen falsy value is silently discarded — { timeout: 30, label: 'server', verbose: true }, none of which is what the caller asked for. ?? checks only for null/undefined, so it leaves 0, "", and false alone:
interface ServerConfig { timeout?: number; label?: string; verbose?: boolean }
function withDefaults(cfg: ServerConfig) { const timeout = cfg.timeout ?? 30; // only null/undefined fall back -- 0 survives const label = cfg.label ?? "server"; // "" survives const verbose = cfg.verbose ?? true; // false survives return { timeout, label, verbose };}
console.log(withDefaults({ timeout: 0, label: "", verbose: false }));// { timeout: 0, label: '', verbose: false }The same distinction applies to the assignment forms added alongside ??: ||= assigns only when the current value is falsy, ??= only when it’s nullish.
let retries = 0;retries ||= 5; // 0 is falsy -- ||= treats it as "unset" and overwrites itconsole.log(retries); // 5
let attempts = 0;attempts ??= 5; // 0 is not nullish -- ??= leaves it aloneconsole.log(attempts); // 0||/||= are still the right tool when you genuinely want “any falsy value counts as absent” (a common, deliberate case). The bug is reaching for them out of habit when the type actually admits 0, "", or false as valid data — which ?./??’s own nullability focus is a hint to check for.
The compiler narrows more than it used to
Section titled “The compiler narrows more than it used to”Three checker improvements since 5.5 remove common null-handling boilerplate — and catch a few more mistakes:
- Inferred type predicates (5.5). A callback like
x => x !== undefinedis now inferred asx is Bird, so.filternarrows without a hand-written guard. The old.filter(Boolean) as Bird[]dance is unnecessary. - Never-initialized variables (5.7). Reading a
letthat no code path ever assigns is an error, even from inside a nested function. - Always-truthy and always-nullish checks (5.6). An expression that can never be nullish on the left of
??, or a function or regex literal used as a condition, is an error.
// @expect-error TS2454 TS2869 TS18048interface Bird { name: string }declare const names: string[];declare const registry: Map<string, Bird>;
const birds = names.map(n => registry.get(n)).filter(b => b !== undefined);// ^? Bird[] — the predicate is inferred, no cast needed
function demo() { let result: number; function print() { console.log(result); } // ❌ used before being assigned (5.7)}
function limit(value: number, max?: number) { return value < max ?? 100; // ❌ left of ?? is never nullish (5.6) // value < (max ?? 100) is what you meant}The non-null assertion ! — a loaded gun
Section titled “The non-null assertion ! — a loaded gun”The postfix ! tells the compiler “trust me, this isn’t null”:
const el = document.getElementById("app")!; // asserts non-nullel.innerHTML = "hi"; // no error — but crashes if #app doesn't existIt erases a real check with zero runtime verification. Sometimes you genuinely know more than the compiler — but every ! is a place you’ve overridden the safety net, and a place a future change can silently break. Prefer a real check (if (!el) throw ...) that narrows honestly; reserve ! for cases you can prove.
Modeling errors: Result vs throwing
Section titled “Modeling errors: Result vs throwing”Thrown exceptions are invisible to the type system — a function’s signature never says what it might throw. A Result type makes failure explicit and type-checked:
declare const input: string;
type Result<T, E> = | { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> { const n = Number(s); if (!Number.isInteger(n) || n < 0) return { ok: false, error: "invalid port" }; return { ok: true, value: n };}
const r = parsePort(input);if (r.ok) r.value; // narrowed to numberelse r.error; // narrowed to stringNow the caller must handle both branches — the compiler won’t let them forget. It is a discriminated union (the ok field is the tag), which brings us to the final tool.
Exhaustiveness with never
Section titled “Exhaustiveness with never”When you switch over a discriminated union, never guarantees you handled every case. Assign the value to never in the default branch: if a new variant is ever added, that assignment fails to compile.
type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.r ** 2; case "square": return s.side ** 2; default: const _exhaustive: never = s; // ✅ s is never here — all cases handled return _exhaustive; }}Add a triangle variant later and the default line errors: Type '{ kind: "triangle" ... }' is not assignable to type 'never'. The compiler just turned “did I update every switch?” from a manual hunt into a build error.
flowchart TB u["Shape = circle or square"] --> c1["case circle: handled"] u --> c2["case square: handled"] c1 --> d["default: assign s to never"] c2 --> d d --> ok["compiles = all cases covered"] d --> err["new variant unhandled = compile error"]
Exhaustiveness outside a switch
Section titled “Exhaustiveness outside a switch”The never-assignment trick is specific to a switch’s default branch. Real code is full of discriminated unions handled with if/else if chains instead — a validation pipeline, a reducer, a router — and the equivalent-looking “just throw in the fallback” pattern gives you no compile-time exhaustiveness at all, even though it reads as if it does:
type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; throw new Error("unhandled shape"); // "exhaustive" only by convention -- nothing checks it}That compiles today, with two variants fully handled. Now add a triangle variant to Shape and change nothing else:
type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; throw new Error("unhandled shape"); // triangle silently falls through to here at runtime}This still compiles with zero errors. Nothing about throw forces the compiler to check that every case was handled first — it’s exhaustiveness by convention, not by proof, and the whole point of the never trick is that convention isn’t enough. Two idioms give an if/else if chain the same real guarantee a switch’s never default has:
// @expect-error TS2345type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function assertNever(x: never): never { throw new Error(`unhandled variant: ${JSON.stringify(x)}`);}
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; return assertNever(s); // s is { kind: "triangle"; ... } here -- not never}assertNever only accepts never, so passing the unhandled s is a compile error the moment a variant is missed — the exact same failure mode the switch version catches, now available for any control-flow shape. If you don’t want a runtime helper at all, satisfies never does the same check with zero function calls, purely at compile time:
// @expect-error TS1360type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; s satisfies never; // ❌ TS1360: { kind: "triangle"; ... } does not satisfy never throw new Error("unhandled shape");}Both forms turn “did I update every if chain?” back into the same build error the switch trick gives you — the earlier two-variant version of each check compiles with zero errors, and only the unhandled triangle addition breaks the build, exactly where it should.
Mental Model
Section titled “Mental Model”never exhaustiveness is not a language feature the compiler runs for you — it’s a small proof you construct by hand, once, and the compiler happens to be very good at checking proofs. A switch’s default: const x: never = s and an if chain’s assertNever(s) / s satisfies never are the same proof in different syntactic clothes: “after every named branch, what’s left over must be nothing.” throw alone proves nothing — it’s just code that runs, with no relationship to the type checker at all, which is exactly why it silently tolerates a missed variant. The ?? vs || distinction is a smaller version of the same discipline: know precisely which set of values a check is actually excluding (null/undefined vs “everything falsy”), and pick the operator whose exclusion set matches your data’s real valid range.
Trade-offs
Section titled “Trade-offs”| Technique | Benefit | Cost |
|---|---|---|
?? / ??= | Only treats null/undefined as absent — 0, "", false survive | Doesn’t help when you do want “any falsy value” treated as absent |
| ` | / | |
switch + never default | One line, reads naturally, matches the shape of the data | Only applies to a literal switch statement |
assertNever(x: never) helper | Works for any control-flow shape (if chains, early returns), also throws a useful message at runtime if it’s ever reached | One shared helper function to introduce and reuse everywhere |
s satisfies never | Zero runtime cost, no helper function needed | Doesn’t throw on its own — still needs a real throw/return after it for the runtime fallback path |
Common Misconceptions
Section titled “Common Misconceptions”throwin the finalelse/defaultbranch is exhaustive, the same as anever-typed default. It isn’t —throwis ordinary code with no relationship to the type checker. Adding a new union variant and forgetting to handle it compiles cleanly either way; only an actualneverassignment,assertNever, orsatisfies neverturns the gap into a compile error.??and||are interchangeable “give me a default” operators. They exclude different sets of values:??excludes onlynull/undefined;||excludes every falsy value. Swapping one for the other silently changes behavior for0,"", andfalse.strictNullChecksalone prevents null-related bugs. It forces you to handle thenull/undefinedcase in the types — it says nothing about whether an||fallback, a!, or athrow-only “exhaustive” branch handles that case correctly.- A non-null assertion
!is roughly as safe as a real null check, just shorter. A real check (if (x == null) throw ...) is verified against the actual runtime value every time;!verifies nothing and simply deletes the type error, which is a very different risk profile.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated null-handling or exhaustiveness logic, check for:
||used to supply a default for a field whose type includes0,"", orfalseas a legitimate value — this is one of the single most common AI-generated bugs, because||and??look interchangeable at a glance.- An
if/else ifchain over a discriminated union ending inthrow new Error(...)with noassertNever/never-typed variable/satisfies never— it reads as exhaustive but proves nothing, and a new variant added later fails silently at the type level. - A non-null assertion
!on a value that came from a boundary (a DOM query, aMap.get, a fetch response) rather than from a local invariant you can actually prove. - A function whose return type promises a
Result/discriminated union but that alsothrows in some paths — mixing both error-handling styles usually means the type signature is lying about what the function can actually do.
🐛 Spot the bug
type Shape =| { kind: "circle"; r: number }| { kind: "square"; side: number }| { kind: "triangle"; base: number; height: number };
function area(s: Shape): number {if (s.kind === "circle") return Math.PI * s.r ** 2;if (s.kind === "square") return s.side ** 2;throw new Error("unhandled shape");}Show the answer
This compiles with zero errors, even though triangle is never handled. The if/else if chain looks exhaustive — every named case returns, and the fallback throws — but nothing about a throw statement is checked against the union’s members. If Shape started with only circle and square and triangle was added later (a very common real-world edit), area would silently compile and then throw "unhandled shape" at runtime for every triangle, with no warning anywhere in the build. The fix is to replace the bare throw with return assertNever(s) (a small helper typed (x: never) => never) or add s satisfies never; immediately before the throw — either one turns the missing triangle case into a compile error the moment it’s introduced, instead of a runtime surprise discovered later.