Skip to content

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.

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.
  • Truthinessif (x) removes null, undefined, 0, "", false from the possibilities.
  • Equalityif (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"]
A discriminant field narrows a union to one member

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.

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.

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.

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.

What is control-flow narrowing?
What defines a discriminated union?
How does assigning to `never` in a default branch help?
What does a function returning `x is string` (a type predicate) do?
What does an assertion function `asserts cond` do after it is called?