How to narrow a union type.
Every technique, one page.
A union type says "one of these." But you can't use it until you know which one. That's narrowing — the process of refining a union to a specific member based on a runtime check. TypeScript understands six narrowing patterns. Learn all six, and unions become the most powerful tool in your type system.
Short answer: Use the right runtime check for the union. Primitives → typeof. Classes → instanceof. Property presence → in. Discriminated unions → === on the discriminant. Arrays → Array.isArray. Custom shapes → a type guard (function isX(v): v is X). Once narrowed inside an if, the compiler knows the specific member and unlocks its properties.
✅ Solution 1 — typeof (primitives)
The most common narrowing — for string | number | boolean unions.
When the union is a mix of primitive types, typeof narrows. TypeScript recognizes the string return of typeof x and refines the union inside the block. This is the technique every TypeScript tutorial starts with, and it covers a surprising amount of real code.
// ─── Narrowing string | number | boolean ───── function format(value: string | number | boolean): string { if (typeof value === 'string') { return value.toUpperCase(); // ✅ narrowed to string } if (typeof value === 'number') { return value.toFixed(2); // ✅ narrowed to number } // value is boolean here — the only remaining case return value ? 'yes' : 'no'; // ✅ narrowed to boolean } // ─── typeof null gotcha ────────────────────── function describe(x: string | null): string { // typeof null === 'object' — typeof can't distinguish null if (x === null) return 'nothing'; // check null explicitly first return x; // ✅ narrowed to string } // ─── typeof + Array.isArray for arrays ─────── function join(x: string | string[]): string { // typeof [] === 'object' — typeof says nothing useful here if (Array.isArray(x)) { return x.join(', '); // ✅ narrowed to string[] } return x; // ✅ narrowed to string } // ─── typeof values cheat sheet ─────────────── // typeof 'x' → 'string' // typeof 1 → 'number' // typeof true → 'boolean' // typeof undefined → 'undefined' // typeof null → 'object' ← gotcha // typeof {} → 'object' // typeof [] → 'object' // typeof () => {} → 'function' // typeof Symbol() → 'symbol' // typeof 1n → 'bigint' console.log(format('hi'), format(3.14), format(true)); console.log(join(['a', 'b']));
function format(value) { if (typeof value === 'string') { return value.toUpperCase(); } if (typeof value === 'number') { return value.toFixed(2); } return value ? 'yes' : 'no'; } function describe(x) { if (x === null) return 'nothing'; return x; } function join(x) { if (Array.isArray(x)) { return x.join(', '); } return x; } console.log(format('hi'), format(3.14), format(true)); console.log(join(['a', 'b']));
typeof for primitives
Narrows string | number | boolean and any primitive union. TypeScript recognizes the typeof x === 'y' check.
The null gotcha
typeof null === 'object'. To narrow null, use x === null explicitly. typeof can't help here.
Array.isArray
typeof [] === 'object' — typeof can't distinguish arrays. Use Array.isArray — TypeScript narrows it to T[].
Remaining type is inferred
After the first two if blocks, value is boolean — no need for a third check. TypeScript tracks the remaining union after exclusions.
✅ Solution 2 — discriminated unions (the pattern)
A shared discriminant field + switch. The cleanest narrowing.
When your union members all share a field with a unique literal value, that field is the discriminant. Checking it narrows to the exact member. This is how you model state machines, API responses, and any "one of these" data that has structure. It's the single most-used narrowing pattern in production TypeScript.
// ─── The discriminated union ───────────────── type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number } | { kind: 'triangle'; base: number; height: number }; // ─── Switch on the discriminant ────────────── function area(s: Shape): number { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; // ✅ narrowed to circle case 'square': return s.side ** 2; // ✅ narrowed to square case 'triangle': return (s.base * s.height) / 2; // ✅ narrowed to triangle default: // s is never here — exhaustive check const _exhaustive: never = s; return _exhaustive; } } // ─── If-else narrows too ───────────────────── function describe(s: Shape): string { if (s.kind === 'circle') return Circle r=${s.radius}Square s=${s.side}; return Triangle b=${s.base}`; // ✅ narrowed to triangle } // ─── API response example ──────────────────── type ApiResult= | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; message: string }; function render (r: ApiResult ): string { switch (r.status) { case 'loading': return 'Loading…'; case 'success': return String(r.data); // ✅ r.data exists case 'error': return r.message; // ✅ r.message exists } } console.log(area({ kind: 'circle', radius: 2 })); console.log(describe({ kind: 'square', side: 3 })); console.log(render({ status: 'success', data: 42 }));
function area(s) { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: const _exhaustive = s; return _exhaustive; } } function describe(s) { if (s.kind === 'circle') return Circle r=${s.radius}Square s={s.base}`; } function render(r) { switch (r.status) { case 'loading': return 'Loading…'; case 'success': return String(r.data); case 'error': return r.message; } } console.log(area({ kind: 'circle', radius: 2 })); console.log(describe({ kind: 'square', side: 3 })); console.log(render({ status: 'success', data: 42 }));
The discriminant field
A shared field (kind, status, type) with a unique literal value per member. That's the tag the compiler uses to narrow.
Switch narrows automatically
switch (s.kind) with a case per literal narrows s inside each case. No manual checks — the compiler tracks the branch.
Exhaustiveness with never
The default case gets s: never. If you add a new member and forget it, the compiler errors. Unions stay safe as they grow.
Impossible states prevented
{ kind: 'loading', data: {...} } is impossible — the loading member has no data field. The union makes it unrepresentable.
✅ Solution 3 — in, instanceof, and custom guards
The remaining techniques for non-primitive, non-discriminated unions.
Sometimes the union has no clean discriminant — just different shapes. in narrows by property presence. instanceof narrows class instances. A custom type guard narrows anything else, including unknown API shapes. Together they cover everything typeof and discriminated unions don't.
// ─── in — narrow by property presence ────── interface Bird { fly(): void; layEggs(): void } interface Fish { swim(): void; layEggs(): void } function move(animal: Bird | Fish): string { if ('fly' in animal) { animal.fly(); // ✅ narrowed to Bird return 'flew'; } animal.swim(); // ✅ narrowed to Fish return 'swam'; } // ─── instanceof — narrow class instances ───── class Cat { meow() { return 'meow'; } } class Dog { bark() { return 'woof'; } } function speak(pet: Cat | Dog): string { if (pet instanceof Cat) { return pet.meow(); // ✅ narrowed to Cat } return pet.bark(); // ✅ narrowed to Dog } // ─── instanceof works with built-ins too ───── function describeError(err: Error | TypeError | RangeError): string { if (err instanceof TypeError) return 'type error'; if (err instanceof RangeError) return 'range error'; return 'error'; } // ─── Custom type guard — narrow anything ───── interface User { id: string; name: string } function isUser(x: unknown): x is User { return ( typeof x === 'object' && x !== null && 'id' in x && 'name' in x && typeof (x as User).id === 'string' && typeof (x as User).name === 'string' ); } function greet(x: unknown): string { if (isUser(x)) { return Hello, ${x.name}`; // ✅ narrowed to User } return 'Hello, stranger'; } // ─── Equality with literals (without a switch) ─ type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; function isSafe(m: Method): boolean { if (m === 'GET') return true; // narrowed to 'GET' if (m === 'DELETE') return false; return false; // narrowed to 'POST' | 'PUT' } console.log(move({ fly() {}, layEggs() {} })); console.log(speak(new Cat()), speak(new Dog())); console.log(greet({ id: '1', name: 'Alice' })); console.log(isSafe('GET'));
function move(animal) { if ('fly' in animal) { animal.fly(); return 'flew'; } animal.swim(); return 'swam'; } class Cat { meow() { return 'meow'; } } class Dog { bark() { return 'woof'; } } function speak(pet) { if (pet instanceof Cat) { return pet.meow(); } return pet.bark(); } function describeError(err) { if (err instanceof TypeError) return 'type error'; if (err instanceof RangeError) return 'range error'; return 'error'; } function isUser(x) { return ( typeof x === 'object' && x !== null && 'id' in x && 'name' in x && typeof x.id === 'string' && typeof x.name === 'string' ); } function greet(x) { if (isUser(x)) { return Hello,${x.name}`; } return 'Hello, stranger'; } function isSafe(m) { if (m === 'GET') return true; if (m === 'DELETE') return false; return false; } console.log(move({ fly() {}, layEggs() {} })); console.log(speak(new Cat()), speak(new Dog())); console.log(greet({ id: '1', name: 'Alice' })); console.log(isSafe('GET'));
in for shape narrowing
'fly' in animal narrows to the member that has fly. Works when members have distinct property names but no discriminant.
instanceof for classes
pet instanceof Cat narrows to Cat. Works with custom classes and built-ins (Date, Error, Map).
Custom guards narrow anything
function isUser(x): x is User — the predicate x is User is what narrows. The body just needs to return a boolean.
Equality with literals
m === 'GET' narrows a literal union to that one value. This is what makes switch work without a discriminant field.
⚠️ Common mistakes
The narrowing bugs that cost real time.
(s: Circle | Square).radius fails — Square has no radius. Fix: narrow first (if ('radius' in s) or discriminant check).
typeof on object unionstypeof {a:1} === typeof {b:2} — both are 'object'. Fix: use a discriminant field or in for object shapes.
null before typeoftypeof null === 'object' — narrows incorrectly. Fix: x === null check before typeof.
as instead of narrowing(x as Circle).radius — bypasses the check. The cast doesn't verify anything at runtime. Fix: narrow with a real check.
No default with never — adding a new union member doesn't error. Fix: always include const _: never = x in the default.
function isUser(x): x is User { return true; } — narrows incorrectly. Fix: the guard body must actually validate the shape.
Two object types with no shared literal field are hard to narrow. Fix: add a kind/status field — the discriminant is what makes narrowing automatic.
if (name) filters '', not just null. Fix: name !== null && name !== undefined if empty is valid.
❓ Frequently asked
How do I narrow a union type in TypeScript?
Use a runtime check that TypeScript understands as a narrowing operation. typeof narrows primitives. instanceof narrows class instances. The in operator narrows by property presence. Equality with literals narrows discriminated unions. Custom type guards narrow with a predicate. Each check tells the compiler which member of the union is in play.
What is a discriminated union?
A discriminated union is a union where each member has a shared property (the discriminant) with a unique literal value. type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }. Checking shape.kind === 'circle' narrows the type to the circle member.
What is a custom type guard?
A type guard is a function that returns a type predicate: function isUser(x: unknown): x is User { return typeof x === 'object' && x !== null && 'name' in x; }. When called in an if, TypeScript narrows x to User inside the block.
Why can't I access a property on a union type?
TypeScript only allows accessing properties that exist on EVERY member of a union. If Shape is Circle | Square and only Circle has radius, then shape.radius fails. Fix: narrow to the specific member first, then access the property.