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.
// 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. Turn this on. If you inherit a codebase without it, turning it 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.
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:
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"]