Unions and Narrowing
Unions let a value be one of several types. Narrowing is how TypeScript works out which one at each point in your code. Together they are the part of the type system that feels genuinely smart, and the part that models real JavaScript best.
Union types
Section titled “Union types”A union is written with | and means “any one of these”.
type Id = string | number;
function format(id: Id): string { return `#${id}`;}You can only access members that exist on every member of the union:
function shout(x: string | number) { return x.toUpperCase(); // error: Property 'toUpperCase' does not exist on type 'string | number'. // Property 'toUpperCase' does not exist on type 'number'.}That’s the union’s whole contract: until you prove which branch you’re in, you get the intersection of the available operations.
Unions can contain literals, objects, functions, null, anything:
type Result = 'ok' | 'error';type Nullable<T> = T | null;type Callback = ((err: Error) => void) | null;Intersection types
Section titled “Intersection types”& combines types — the value must satisfy all of them at once.
interface HasId { id: string }interface HasTimestamps { createdAt: Date; updatedAt: Date }
type Entity = HasId & HasTimestamps;
const e: Entity = { id: 'a1', createdAt: new Date(), updatedAt: new Date(),};Useful for mixing in shared fields:
type WithLoading<T> = T & { isLoading: boolean };Two things to know:
Conflicting primitive members collapse to never.
type Bad = { x: number } & { x: string };type X = Bad['x']; // never — no value is bothThe declaration itself doesn’t error; you find out at the assignment site, with a confusing
message. interface extends catches the same mistake immediately — see
functions and objects.
Intersecting unrelated primitives gives never.
type Impossible = string & number; // neverNarrowing
Section titled “Narrowing”Narrowing is the compiler’s control-flow analysis: it tracks what a variable’s type must be along each path through your code. Every technique below is something TypeScript recognises specially.
typeof
Section titled “typeof”function pad(value: string | number, width: number): string { if (typeof value === 'number') { return value.toFixed(2).padStart(width); // value: number } return value.padStart(width); // value: string}TypeScript understands all of typeof’s results: 'string', 'number', 'bigint', 'boolean',
'symbol', 'undefined', 'object', 'function'.
instanceof
Section titled “instanceof”For anything constructed from a class:
function describe(err: Error | string) { if (err instanceof TypeError) { return `type error: ${err.message}`; // err: TypeError } if (err instanceof Error) { return err.message; // err: Error } return err; // err: string}instanceof narrows using the constructor’s prototype property, so it works with your own
classes too. It fails across realms (iframes, worker boundaries, some bundler setups) — that’s a
JavaScript limitation, not a TypeScript one.
The in operator
Section titled “The in operator”Checks for a property, which is the natural way to distinguish object shapes:
interface Circle { radius: number }interface Square { side: number }
function area(shape: Circle | Square): number { if ('radius' in shape) { return Math.PI * shape.radius ** 2; // shape: Circle } return shape.side ** 2; // shape: Square}Since TypeScript 4.9, in also narrows an unknown-typed value to object with that key present:
function f(x: unknown) { if (typeof x === 'object' && x !== null && 'name' in x) { x.name; // unknown, but accessible }}Truthiness
Section titled “Truthiness”function print(name?: string) { if (name) { console.log(name.toUpperCase()); // name: string }}
// Early return / guard clause — the common idiom.function load(el: HTMLElement | null) { if (!el) return; el.focus(); // el: HTMLElement}x != null (loose inequality) is special-cased: it excludes both null and undefined in one
check, and TypeScript understands it.
Equality narrowing
Section titled “Equality narrowing”Comparing two values narrows both of them:
function compare(a: string | number, b: string | boolean) { if (a === b) { a.toUpperCase(); // a: string b.toUpperCase(); // b: string — the only overlapping type }}Comparing against a literal narrows to that literal:
function move(dir: 'up' | 'down' | 'left' | 'right') { if (dir === 'up' || dir === 'down') { dir; // 'up' | 'down' } else { dir; // 'left' | 'right' }}If the types have no overlap at all, TypeScript reports it rather than narrowing to never:
const s: string = 'a';if (s === 42) {} // error: This comparison appears to be unintentional // because the types 'string' and 'number' have no overlap.Array.isArray and other built-in guards
Section titled “Array.isArray and other built-in guards”Array.isArray is declared with a type predicate in the standard library, so it narrows:
function normalise(input: string | string[]): string[] { return Array.isArray(input) ? input : [input];}Assignment narrowing
Section titled “Assignment narrowing”Assigning to a variable narrows it to the assigned value’s type, within its declared type:
let x: string | number = 'hello';x.toUpperCase(); // x: stringx = 42;x.toFixed(); // x: numberx.toUpperCase(); // errorThe declared type never changes — only what the compiler knows at each point.
Aliased conditions
Section titled “Aliased conditions”Since TypeScript 4.4, narrowing survives being stored in a const:
function f(x: string | number | null) { const isString = typeof x === 'string'; if (isString) { x.toUpperCase(); // works — the condition was aliased }}This requires const (not let) and a directly-analysable condition. Destructured discriminants
work too:
type Action = { kind: 'add'; value: number } | { kind: 'reset' };
function reduce(action: Action) { const { kind } = action; if (kind === 'add') { action.value; // narrowed via the destructured discriminant }}Discriminated unions
Section titled “Discriminated unions”The single most useful pattern in TypeScript. Give every member of a union a shared property with a distinct literal type — the discriminant — and narrowing becomes trivial and exhaustive.
type NetworkState = | { status: 'idle' } | { status: 'loading'; startedAt: number } | { status: 'success'; data: string[] } | { status: 'error'; error: Error; retryable: boolean };
function render(state: NetworkState): string { switch (state.status) { case 'idle': return 'Ready'; case 'loading': return `Loading since ${state.startedAt}`; case 'success': return `${state.data.length} items`; // `data` only exists here case 'error': return state.retryable ? 'Retrying' : state.error.message; }}Each case narrows state to exactly one member, so you get precise property access with no casts
and no optional chaining. The alternative — one interface with everything optional — forces
defensive checks everywhere and lets impossible states exist ({ status: 'idle', data: [...] }).
Requirements for a discriminant:
- The property must exist on every member of the union.
- Its type must be a literal (string, number, or boolean literal) — not
string.
// This does NOT discriminate: `kind` is `string` on both.type Bad = { kind: string; a: number } | { kind: string; b: number };Watch for literal widening. If you build the value in a mutable variable, status widens to
string and discrimination breaks — annotate or use as const.
User-defined type guards
Section titled “User-defined type guards”When narrowing needs logic TypeScript can’t infer, write a function returning x is T.
interface Cat { meow(): void }interface Dog { bark(): void }
function isCat(animal: Cat | Dog): animal is Cat { return 'meow' in animal;}
function speak(animal: Cat | Dog) { if (isCat(animal)) { animal.meow(); // Cat } else { animal.bark(); // Dog }}The animal is Cat return type is a type predicate. To the compiler, “this function returned
true” means “the argument is a Cat”. The parameter named in the predicate must be a parameter
of that function.
Guards over unknown are how you safely handle external data:
interface User { id: number; email: string }
function isUser(value: unknown): value is User { return ( typeof value === 'object' && value !== null && 'id' in value && typeof (value as Record<string, unknown>).id === 'number' && 'email' in value && typeof (value as Record<string, unknown>).email === 'string' );}
const parsed: unknown = JSON.parse(raw);if (isUser(parsed)) { parsed.email; // safe}Inferred type predicates (TypeScript 5.5+)
Section titled “Inferred type predicates (TypeScript 5.5+)”From 5.5, TypeScript infers predicates for simple functions, which fixes the classic filter
annoyance:
const values = ['a', null, 'b'];
// TS 5.5+: inferred as (x: string | null) => x is stringconst isNonNull = (x: string | null) => x !== null;
const filtered = values.filter(isNonNull); // string[]Before 5.5 (or for anything more complex) you write the predicate explicitly:
const filtered = values.filter((x): x is string => x !== null);Guards on array elements
Section titled “Guards on array elements”Array.prototype.filter has an overload taking a predicate, which is why the above works. find
does too. some and every do not narrow the array itself.
Assertion functions
Section titled “Assertion functions”An assertion function throws if a condition doesn’t hold, and tells the compiler the condition held afterwards. Two forms:
// Form 1: asserts a condition.function assert(condition: unknown, message = 'Assertion failed'): asserts condition { if (!condition) throw new Error(message);}
// Form 2: asserts a type for a specific parameter.function assertIsString(value: unknown): asserts value is string { if (typeof value !== 'string') { throw new TypeError(`Expected string, got ${typeof value}`); }}Use:
function process(input: unknown) { assertIsString(input); input.toUpperCase(); // input: string from here on}
function get(map: Map<string, number>, key: string): number { const value = map.get(key); assert(value !== undefined, `missing key: ${key}`); return value; // number}Assertion functions differ from type guards in that they narrow for the rest of the enclosing
scope rather than inside an if. They also run — the throw is real, unlike a type assertion
(as) which does nothing at runtime.
Exhaustiveness checking with never
Section titled “Exhaustiveness checking with never”When you handle every member of a union, the residual type is never. Assign it to a never
variable and the compiler will error the day someone adds a new member.
type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number } | { kind: 'rect'; width: number; height: number };
function area(shape: Shape): number { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; case 'square': return shape.side ** 2; case 'rect': return shape.width * shape.height; default: { const exhaustive: never = shape; throw new Error(`Unhandled shape: ${JSON.stringify(exhaustive)}`); } }}Add { kind: 'triangle'; base: number; height: number } to Shape and you get:
error TS2322: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'.pointing at the exact function you forgot to update. This turns “add a case” from a code-review concern into a compiler error.
A reusable helper reads better:
function assertNever(value: never, message = 'Unexpected value'): never { throw new Error(`${message}: ${JSON.stringify(value)}`);}
// default: return assertNever(shape, 'Unhandled shape');Control-flow analysis: the fine print
Section titled “Control-flow analysis: the fine print”The compiler simulates every path. A few behaviours worth internalising:
Narrowing resets after a function call it can’t see through.
let value: string | null = getValue();if (value !== null) { doSomething(); value.length; // still narrowed — TS assumes calls don't mutate locals}Narrowing survives into a closure only if the variable is never reassigned.
function f(x: string | null) { if (x !== null) { setTimeout(() => x.length, 0); // fine — `x` is never reassigned }}
function g(x: string | null) { if (x !== null) { setTimeout(() => x.length, 0); // error: 'x' is possibly 'null' } x = null; // this assignment is what breaks it}Because x is assigned somewhere in g, the compiler can’t know its value when the callback
eventually runs, so it falls back to the declared type. Copying into a const fixes it.
Object properties narrow, but the narrowing is fragile.
interface Box { value?: string }
function f(box: Box) { if (box.value) { box.value.length; // fine mutate(box); box.value.length; // still considered narrowed — TS can't see the mutation }}The safe habit is to copy to a local first:
const value = box.value;if (value) { /* `value` is string, and nothing can change it */ }Optional chaining narrows too.
function f(user?: { profile?: { name: string } }) { if (user?.profile) { user.profile.name; // both narrowed }}Key points
Section titled “Key points”- A union gives you only the members common to all branches until you narrow.
- Intersections merge object types; conflicting primitive members become
never. - Narrowing works via
typeof,instanceof,in, truthiness, equality, assignment, andArray.isArray— plusconst-aliased conditions since TS 4.4. - Discriminated unions (shared literal property) are the default way to model variants.
x is Tguards andasserts x is Tfunctions extend narrowing to your own logic — and their bodies are unchecked, so get them right.- Assign the residual union to
neverto make missing cases a compile error. - Narrowing on object properties is fragile; copy to a local when it matters.