Skip to content

TypeScript Type Narrowing Explained

The ways TypeScript narrows a type inside a branch, grouped by technique: built-in guards, custom predicates, assertion functions, discriminated unions, equality and truthiness flow, and aliased and deferred narrowing. Each row pairs the technique with a one-line example.

Narrowing is how TypeScript turns a wide type into a narrower one inside a code branch, so it can prove a property access is safe. Every check (typeof, in, a predicate, a discriminator) tells the compiler: from here, the value is more specific. The techniques below are the working set.

Reference table · 23 entries
23 of 23 rows
Built-in guards
Narrows primitives: string, number, boolean, and more.if (typeof x === 'string') x.toUpperCase()
Narrows to a class or constructable instance.if (e instanceof Error) e.message
Narrows by checking a property exists on the value.if ('id' in obj) obj.id
Narrows any value to an array; narrows a union to its array member.if (Array.isArray(x)) x.map(f)
Custom guards
A predicate function whose return narrows the argument.function isUser(x): x is User { return !!x.id }
A predicate can narrow to a union of types, not only a single one.function isKey(x): x is string | number
An intersection predicate keeps the input type and adds fields to it.function hasId(x): x is x & { id: string }
An assertion function that throws unless x is the type.function assertUser(x): asserts x is User
Discriminated unions
Narrows a union by a shared literal field via switch or if.type E = { kind: 'a'; a: number } | { kind: 'b'; b: string }
Each case narrows to one member of the union.switch (u.kind) { case 'a': u.a }
Narrows an untagged union when a property exists on one member only.if ('b' in u) u.b
Each case is a condition; the matched case narrows the value.switch (true) { case typeof x === 'string': }
The default case assigns to never, proving the switch is exhaustive.default: { const x: never = u }
Equality & flow
Removes null and undefined after an equality check.if (x === null) return; x.toFixed()
Removes falsy values (0, '', null, undefined, NaN).if (!x) return; // x is defined here
A truthy chained property proves the base value is present.if (user?.active) user.name
Non-null assertion removes null and undefined without a check.x!.toFixed(2)
Definite assignment: declares that x is assigned before it is read.let name!: string; init()
Narrowing applies inside the branch and after early returns.if (!arr) return; arr.map(...)
Aliased & deferred
A const boolean holding a check narrows wherever it is tested.const isStr = typeof x === 'string'; if (isStr) …
A const variable keeps its narrowing inside closures that capture it.const s = get(); if (s) use(() => s.length)
Since TS 5.4, a let or parameter keeps narrowing in closures after its last assignment.let n: string | null = get(); if (n) use(() => n.length)
unknown cannot be used until a guard narrows it to a real type.if (typeof u === 'string') u.length