Type Narrowing
Date: 2026-09-28
Inside a branch, the compiler knows more than the declaration says — because it follows your runtime checks through the control flow. Write ordinary JavaScript checks and the type shrinks to match; end a switch with a
neverassignment and forgetting a case becomes a compile error.
Type narrowing is the compiler refining a variable’s declared type to a smaller one at a particular point in the code, based on the runtime checks, assignments and returns that lead to that point. The mechanism is control-flow analysis: the checker walks every path and tracks what each check has ruled out.
The checks it understands
CHECK string | number | null | Order | Refund
──────────────────────────── ──────────────────────────────────────
if (x === null) return string | number | Order | Refund
typeof x === 'string' string (in the branch)
x instanceof Order Order (classes only)
'refundedAt' in x Refund (key existence)
x.kind === 'refund' Refund (discriminant)
if (!x) removes null — and '' and 0 ← careful
Array.isArray(x) the array members
isRefund(x) Refund (your predicate)
Truthiness narrowing is the trap in that list. if (!total) return meant to catch undefined also catches 0, and a £0 order silently disappears. Compare with == null when the intent is “missing”.
Discriminated unions and exhaustiveness
A discriminated union is a union whose members share a literal-typed property — the discriminant — so checking that one property narrows to exactly one member. It’s the payoff of modelling states as a union rather than a bag of optionals (Type Systems has the design argument).
type Payment =
| { method: 'card'; last4: string }
| { method: 'paypal'; email: string }
| { method: 'klarna'; instalments: 3 | 4 }
function label(p: Payment): string {
switch (p.method) {
case 'card': return `•••• ${p.last4}` // p is the card member
case 'paypal': return p.email // p.last4 is an error here
case 'klarna': return `${p.instalments} payments`
default: {
const unreachable: never = p // ← every case handled, so p is never
throw new Error(`Unhandled method: ${JSON.stringify(unreachable)}`)
}
}
}The never line is the load-bearing part. Add { method: 'applepay' } to the union and this line stops compiling — p is now the Apple Pay member, which isn’t assignable to the empty set. Without it, the new method falls through silently and returns undefined at runtime. The throw still matters: the type says unreachable, but data from outside can make it reachable (Type Safety Boundaries).
Writing your own guards
A type predicate — return type x is T — tells the compiler what a true result proves:
function isRefund(x: Order | Refund): x is Refund {
return 'refundedAt' in x
}- The predicate is trusted, not checked. Get the body wrong and the compiler narrows on a lie. It’s an assertion wearing a function’s clothes
- Inferred predicates (TypeScript 5.5): a boolean-returning function whose check matches a narrowing is inferred as a predicate automatically — the practical win is
orders.filter(o => o !== null)finally producingOrder[]rather than(Order | null)[] - Assertion functions —
asserts x is T— narrow for the rest of the scope by throwing otherwise. Good at boundaries:assertIsOrder(json)then use it
Where narrowing is lost — or wrongly kept
Callbacks, historically. A narrowed let or parameter used inside a closure reverted to its declared type, because the callback might run after a reassignment. Since 5.4 the checker keeps the narrowing when the closure is created after the last assignment. It still gives up if anything reassigns the variable inside a nested function.
Property narrowing survives function calls — optimistically.
if (cart.discount !== null) {
recalculate(cart) // might set cart.discount = null
cart.discount.amount // still narrowed. compiles. may throw
}The checker doesn’t invalidate property narrowings across calls; doing so would make narrowing nearly useless. The cost is this hole. Copy to a const first when the call can mutate.
Destructuring keeps the link only under conditions. Since 4.6, with a union whose members share a payload property of different types, const { kind, payload } = action then checking kind narrows payload — but only for individual properties destructured into a const, or a parameter never reassigned. Destructure with let, reassign, or split across statements, and the two variables become independent again.
Why it matters beyond tidiness
- It removes defensive code. Once narrowed, no
?.chains and no!— each of which is a place a real bug could hide - Exhaustiveness turns a product change into a compile error list. Adding a payment method, an order status or a consent state shows every place that needs a decision
- It keeps the checks honest. Narrowing only follows real runtime checks;
asskips them — Type Safety Boundaries