Handbooks / TypeScript / Chapter 3

Narrowing

7 pages · ~5 min✓ Reviewed

Builds on Interfaces, Types and Unions. Next up: Generics.

TypeScriptPage 1

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 primitives
  • typeof gives: 'string' 'number' 'bigint' 'boolean' 'symbol' 'undefined' 'object' 'function'

2. Null and falsy traps

  • typeof null is 'object', so typeof x === 'object' keeps null until x !== null
  • if (x) also drops valid 0, '' and NaN
CheckRemoves
if (x)null, undefined, 0, '', NaN
x != nullnull, undefined only
x !== undefinedundefined 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
typescript
let id: string | undefined = load();
if (id) {
  setTimeout(() => id.length, 0); // error
}
id = undefined;

id is possibly undefined inside the callback

Fix

Copy to a const before the callback: const s = id;

TypeScriptPage 2

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 pet keeps union members that have (or may have) swim
  • if ('swim' in pet) pet.swim()
CheckNarrows to
a === b, a !== bthe type both share
x == nulldrops null and undefined together
'key' in objmembers with the property

3. Array.isArray

Input typeTrue branch
string | string[]string[]
unknownany[]
T | T[]T[]
typescript
const list = Array.isArray(x) ? x : [x];

standard way to split T | T[]

Remember

instanceof needs a runtime class. For interfaces, use in or a tag field.

TypeScriptPage 3

Discriminated Unions

1. Shape of a Union

  • Members share one tag field with a literal type
  • Each member has its own tag value
ts
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) or if narrows to exactly one member
  • Tag can be a string, number, boolean, null or undefined literal
  • Plain string tag: narrowing fails

3. Tagged vs Optional Fields

Optional fieldsTagged union
Type{ ok?; data?; error? }one member per state
Illegal stateok with error, or neitherunrepresentable

4. Result Pattern

ts
type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: Error };
if (r.ok) r.value; else r.error;

if (r.ok) narrows r

Remember

Tag must be a literal, else no narrowing.

TypeScriptPage 4

Exhaustiveness with never

1. The never leftover

  • Each case removes one member from the union
  • When all are handled, default sees never
  • Nothing can reach it, so s has no valid value

2. Enforcing it

typescript
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

Add a triangle member
  1. 1Union growsnew kind
  2. 2Leftovers is Triangle, not never
  3. 3Compile errorevery unhandled switch
assertNeverInline check
Defaultreturn assertNever(s)const _exhaustive: never = s;
Runtimethrowsnothing

3. Gotcha

Compile time only

JSON or API data can carry an unknown kind and reach default. Validate untrusted input first.

TypeScriptPage 5

Custom Guards and Assertion Functions

1. Type Guards

  • p is Fish is a type predicate
  • True branch narrows to Fish; false branch excludes Fish
  • Return type boolean alone does not narrow
typescript
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

Body is unchecked

TypeScript trusts the predicate. A guard that returns true for everything silently gives unsound types.

2. Assertion Functions

typescript
function assertIsString(v: unknown): asserts v is string {
  if (typeof v !== 'string') throw new Error()
}
assertIsString(x)
x.toUpperCase()  // x: string
  • asserts cond has no type: narrows by condition, e.g. asserts x != null
GuardAssertion
Signaturep is Tasserts v is T
Failurereturns falsethrows
Narrowsboth branchesrest of scope
TypeScriptPage 6

Narrowing Cheat Sheet

1. Checks and when to pick them

CheckNarrowsPick for
typeofprimitives, functionunknown input
instanceofclass instancesclass hierarchies
inproperty presenceobject unions
=== / == nullliterals, nulltags, null checks

2. Tag + exhaustiveness

typescript
switch (s.kind) {
  case 'circle': return s.r
  case 'square': return s.side
  default: return assertNever(s)
}

literal tag + switch

  • New member: default is no longer never, so the compile fails

3. Guard vs assertion

FormDoesNarrows
x is Treturns booleanboth branches
asserts x is Tthrows on failurerest of scope

4. Pitfalls

  • typeof null is 'object'
  • Truthiness drops 0 and ''
  • Interfaces cannot use instanceof
  • Guard bodies are unchecked
Compile time only

assertNever cannot stop bad JSON at runtime. Validate untrusted data.

TypeScriptPage 7

Check yourself

x is string[] | null. Why does if (typeof x === 'object') { x.length } fail to compile?
  • typeof null is 'object', so null stays in the type
  • Check x !== null instead
function f(n: number | undefined) { if (n) return n; return 'none' }. What does f(0) return?
  • 'none': truthiness drops 0, '' and NaN too
  • Use n !== undefined when falsy values are valid
function isFish(p: Fish | Bird): boolean { return 'swim' in p }. Why does if (isFish(p)) p.swim() fail?
  • A boolean return type does not narrow
  • Declare the return type as p is Fish
function isFish(p: Fish | Bird): p is Fish {
  return 'swim' in p
}