Handbooks / TypeScript / Chapter 3
Narrowing
7 pages · ~5 min✓ Reviewed
Builds on Interfaces, Types and Unions. Next up: Generics.
Narrowing Mental Model
1. What narrowing is
- Narrowing refines a union inside a branch, using control-flow analysis of runtime checks
typeof x === 'string'narrows primitivestypeofgives: 'string' 'number' 'bigint' 'boolean' 'symbol' 'undefined' 'object' 'function'
2. Null and falsy traps
typeof nullis 'object', sotypeof x === 'object'keeps null untilx !== nullif (x)also drops valid0,''andNaN
| Check | Removes |
|---|---|
if (x) | null, undefined, 0, '', NaN |
x != null | null, undefined only |
x !== undefined | undefined only |
3. Reassignment and closures
- A closure made before the last assignment loses narrowing
- Assignment narrows to the assigned type:
let x: string | number = 'a'is a string - Since TS 5.4, closures created after the last assignment keep it
let id: string | undefined = load(); if (id) { setTimeout(() => id.length, 0); // error } id = undefined;
id is possibly undefined inside the callback
Copy to a const before the callback: const s = id;
Built-in Narrowing Operators
1. instanceof
- instanceof checks the prototype chain, narrows to the class type
if (e instanceof Error) { e.message }
- Interfaces and type aliases are erased, so no
instanceof - Fails across realms (iframes): each has its own
Error
2. in and equality
'swim' in petkeeps union members that have (or may have)swimif ('swim' in pet) pet.swim()
| Check | Narrows to |
|---|---|
a === b, a !== b | the type both share |
x == null | drops null and undefined together |
'key' in obj | members with the property |
3. Array.isArray
| Input type | True branch |
|---|---|
string | string[] | string[] |
unknown | any[] |
T | T[] | T[] |
const list = Array.isArray(x) ? x : [x];standard way to split T | T[]
instanceof needs a runtime class. For interfaces, use in or a tag field.
Discriminated Unions
1. Shape of a Union
- Members share one tag field with a literal type
- Each member has its own tag value
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'square'; side: number };
if (s.kind === 'circle') s.r;
else s.side;s: Shape; each branch sees one member
2. Narrowing and the Literal Rule
switch (s.kind)orifnarrows to exactly one member- Tag can be a string, number, boolean, null or undefined literal
- Plain
stringtag: narrowing fails
3. Tagged vs Optional Fields
| Optional fields | Tagged union | |
|---|---|---|
| Type | { ok?; data?; error? } | one member per state |
| Illegal state | ok with error, or neither | unrepresentable |
4. Result Pattern
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: Error };
if (r.ok) r.value; else r.error;if (r.ok) narrows r
Tag must be a literal, else no narrowing.
Exhaustiveness with never
1. The never leftover
- Each
caseremoves one member from the union - When all are handled,
defaultsees never - Nothing can reach it, so
shas no valid value
2. Enforcing it
function assertNever(x: never): never { throw new Error('Unexpected: ' + x) } switch (s.kind) { case 'circle': return 'c' case 'square': return 'q' default: return assertNever(s) }
default passes the leftover to a never parameter
- 1Union growsnew kind
- 2Leftovers is Triangle, not never
- 3Compile errorevery unhandled switch
| assertNever | Inline check | |
|---|---|---|
| Default | return assertNever(s) | const _exhaustive: never = s; |
| Runtime | throws | nothing |
3. Gotcha
JSON or API data can carry an unknown kind and reach default. Validate untrusted input first.
Custom Guards and Assertion Functions
1. Type Guards
p is Fishis a type predicate- True branch narrows to Fish; false branch excludes Fish
- Return type
booleanalone does not narrow
function isFish(p: Fish | Bird): p is Fish { return 'swim' in p } if (isFish(p)) p.swim() // Fish else p.fly() // Bird
predicate is the contract
TypeScript trusts the predicate. A guard that returns true for everything silently gives unsound types.
2. Assertion Functions
function assertIsString(v: unknown): asserts v is string { if (typeof v !== 'string') throw new Error() } assertIsString(x) x.toUpperCase() // x: string
asserts condhas no type: narrows by condition, e.g.asserts x != null
| Guard | Assertion | |
|---|---|---|
| Signature | p is T | asserts v is T |
| Failure | returns false | throws |
| Narrows | both branches | rest of scope |
Narrowing Cheat Sheet
1. Checks and when to pick them
| Check | Narrows | Pick for |
|---|---|---|
typeof | primitives, function | unknown input |
instanceof | class instances | class hierarchies |
in | property presence | object unions |
=== / == null | literals, null | tags, null checks |
2. Tag + exhaustiveness
switch (s.kind) {
case 'circle': return s.r
case 'square': return s.side
default: return assertNever(s)
}literal tag + switch
- New member:
defaultis no longer never, so the compile fails
3. Guard vs assertion
| Form | Does | Narrows |
|---|---|---|
x is T | returns boolean | both branches |
asserts x is T | throws on failure | rest of scope |
4. Pitfalls
typeof nullis'object'- Truthiness drops
0and'' - Interfaces cannot use
instanceof - Guard bodies are unchecked
assertNever cannot stop bad JSON at runtime. Validate untrusted data.
Check yourself
x is string[] | null. Why does if (typeof x === 'object') { x.length } fail to compile?
typeof nullis'object', sonullstays in the type- Check
x !== nullinstead
function f(n: number | undefined) { if (n) return n; return 'none' }. What does f(0) return?
'none': truthiness drops0,''andNaNtoo- Use
n !== undefinedwhen falsy values are valid
function isFish(p: Fish | Bird): boolean { return 'swim' in p }. Why does if (isFish(p)) p.swim() fail?
- A
booleanreturn type does not narrow - Declare the return type as
p is Fish
function isFish(p: Fish | Bird): p is Fish { return 'swim' in p }