Tags: web-dev concept

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 never assignment 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 producing Order[] 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; as skips them — Type Safety Boundaries