Skip to content

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.

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 }));
Open in TS Playground

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 it
console.log(retries); // 5
let attempts = 0;
attempts ??= 5; // 0 is not nullish -- ??= leaves it alone
console.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.

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 !== undefined is now inferred as x is Bird, so .filter narrows without a hand-written guard. The old .filter(Boolean) as Bird[] dance is unnecessary.
  • Never-initialized variables (5.7). Reading a let that 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 TS18048
interface 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 postfix ! tells the compiler “trust me, this isn’t null”:

const el = document.getElementById("app")!; // asserts non-null
el.innerHTML = "hi"; // no error — but crashes if #app doesn't exist

It 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.

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 number
else r.error; // narrowed to string

Now 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.

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"]
never as an exhaustiveness proof

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 TS2345
type 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 TS1360
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;
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.

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.

TechniqueBenefitCost
?? / ??=Only treats null/undefined as absent — 0, "", false surviveDoesn’t help when you do want “any falsy value” treated as absent
`/
switch + never defaultOne line, reads naturally, matches the shape of the dataOnly applies to a literal switch statement
assertNever(x: never) helperWorks for any control-flow shape (if chains, early returns), also throws a useful message at runtime if it’s ever reachedOne shared helper function to introduce and reuse everywhere
s satisfies neverZero runtime cost, no helper function neededDoesn’t throw on its own — still needs a real throw/return after it for the runtime fallback path
  • throw in the final else/default branch is exhaustive, the same as a never-typed default. It isn’t — throw is 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 actual never assignment, assertNever, or satisfies never turns the gap into a compile error.
  • ?? and || are interchangeable “give me a default” operators. They exclude different sets of values: ?? excludes only null/undefined; || excludes every falsy value. Swapping one for the other silently changes behavior for 0, "", and false.
  • strictNullChecks alone prevents null-related bugs. It forces you to handle the null/undefined case in the types — it says nothing about whether an || fallback, a !, or a throw-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.

Before trusting generated null-handling or exhaustiveness logic, check for:

  • || used to supply a default for a field whose type includes 0, "", or false as a legitimate value — this is one of the single most common AI-generated bugs, because || and ?? look interchangeable at a glance.
  • An if/else if chain over a discriminated union ending in throw new Error(...) with no assertNever/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, a Map.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 also throws 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.

What does strictNullChecks change?
How does ?? differ from || for supplying a fallback?
What is the risk of the non-null assertion !?
How does the never exhaustiveness trick help when a new union variant is added?
What does this print, and why?
interface ServerConfig { timeout?: number }
function withDefaults(cfg: ServerConfig) {
  const timeout = cfg.timeout || 30;
  return timeout;
}
console.log(withDefaults({ timeout: 0 }));
Does this compile, given that triangle is never handled?
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");
}