Narrowing
The point of unions is narrowing
Section titled “The point of unions is narrowing”A union is only useful because you can narrow it. TypeScript reads your control flow — if, switch, typeof, return — and shrinks the type within each branch to the case that must be true there. This is control-flow analysis, and it’s one of the smartest parts of the compiler.
function printId(id: string | number) { if (typeof id === "string") { id.toUpperCase(); // ✅ here id is string } else { id.toFixed(2); // ✅ here id is number }}Inside the if, the type is string; the else branch removes string from the union, leaving number. You didn’t cast anything — the compiler narrowed based on a real runtime check it understands.
The built-in narrowing operators
Section titled “The built-in narrowing operators”TypeScript understands several ordinary JavaScript checks and narrows from them:
typeof x— narrows primitives:"string","number","boolean","object","function","undefined","symbol","bigint".instanceof— narrows to a class from its prototype chain."key" in obj— narrows object unions by presence of a property.- Truthiness —
if (x)removesnull,undefined,0,"",falsefrom the possibilities. - Equality —
if (x === "a")narrows to that literal; comparing two variables narrows both.
function area(shape: Circle | Square) { if ("radius" in shape) { return Math.PI * shape.radius ** 2; // shape is Circle } return shape.side ** 2; // shape is Square}Discriminated unions: the pattern to reach for
Section titled “Discriminated unions: the pattern to reach for”The cleanest way to model “one of several object shapes” is a discriminated union: every member shares a common literal field (the discriminant or tag), and switching on that field narrows to the right member.
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "rect"; width: number; height: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; // s is the circle member case "square": return s.side ** 2; case "rect": return s.width * s.height; }}flowchart TB u["Shape (union) circle | square | rect"] -->|switch on kind| c["kind === circle → radius available"] u --> sq["kind === square → side available"] u --> r["kind === rect → width, height available"]
This pattern is everywhere: Redux actions, API responses, state machines, result types. When you find yourself reaching for a class hierarchy to model variants, a discriminated union is usually simpler and safer.
Exhaustiveness with never
Section titled “Exhaustiveness with never”Here’s the payoff. Add a default branch that assigns the value to never, and the compiler will error the day someone adds a new union member and forgets to handle it:
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; case "square": return s.side ** 2; case "rect": return s.width * s.height; default: const _exhaustive: never = s; // ❌ errors if a new kind is unhandled return _exhaustive; }}In the default, every known case has been narrowed away, so s is never. Add a "triangle" member and s is no longer never there — the assignment fails to compile, pointing you straight at the missing case. This turns “did I handle every variant?” from a code-review question into a compile error.
Custom type guards: x is T
Section titled “Custom type guards: x is T”When narrowing needs custom logic, write a type-guard function whose return type is a type predicate x is T:
function isString(x: unknown): x is string { return typeof x === "string";}
function handle(v: unknown) { if (isString(v)) { v.toUpperCase(); // ✅ v narrowed to string by the guard }}The predicate is a promise to the compiler: “if this returns true, treat the argument as T.” It’s how you narrow unknown from external data, and how libraries expose validation that the type system trusts.
Assertion functions: asserts x is T
Section titled “Assertion functions: asserts x is T”A cousin of the type guard is the assertion function, which throws instead of returning a boolean and narrows for the rest of the scope:
function assert(cond: unknown, msg: string): asserts cond { if (!cond) throw new Error(msg);}
function use(v: string | null) { assert(v !== null, "v must be set"); v.toUpperCase(); // ✅ v is string from here on — the assert removed null}After the call, the compiler knows the asserted condition held (or execution would have thrown), so it narrows accordingly. This is how assert-style helpers and Node’s assert give you type narrowing for free.