Narrowing, unions and exhaustive checks
Discriminated unions, built-in and user-defined guards, the satisfies operator, and using never to prove you handled every case.
Discriminated unions
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rect'; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) { // the discriminant drives the narrowing
case 'circle':
return Math.PI * shape.radius ** 2; // only the circle payload exists here
case 'square':
return shape.side ** 2;
case 'rect':
return shape.width * shape.height;
}
}
// an if chain narrows just as well
function ratio(shape: Shape): number | null {
if (shape.kind !== 'rect') return null;
return shape.width / shape.height; // narrowed to the rect branch
}
// shape.radius; // error: radius does not exist on every branch| Guard | Narrows when | Watch out for |
|---|---|---|
typeof x === 'string' | The value is a primitive | typeof null is 'object' |
x instanceof Date | The value is a class instance | Objects from another realm fail the check |
'key' in x | A property exists on the union | It checks presence, not the value's type |
Array.isArray(x) | The value is an array | Subclasses pass too |
x is T predicate | Your own rule returns true | The compiler trusts it even when it is wrong |
satisfies T | Never - it only validates | It checks a value without widening its type |
A discriminated union is the most useful shape in TypeScript: one literal property that tells the compiler which payload can exist. Model a result, a UI state or an event with it and impossible combinations become unwritable.
Predicates, in and satisfies
interface User { id: string; name: string }
// a user-defined guard is a normal function with a return type annotation
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null
&& typeof (value as User).id === 'string'
&& typeof (value as User).name === 'string';
}
function greet(value: unknown): string {
if (isUser(value)) return 'Hello ' + value.name; // value is User from here on
return 'Hello stranger';
}
// 'in' narrows by the presence of a property
type Admin = { permissions: string[] };
type Guest = { expiresAt: Date };
function label(account: Admin | Guest): string {
if ('permissions' in account) return account.permissions.length + ' permissions';
return account.expiresAt.toISOString();
}
// built-in guards compose in a single expression
function firstLine(input: string | string[] | Error): string {
if (typeof input === 'string') return input.split('\n')[0];
if (input instanceof Error) return input.message;
return input[0] ?? '';
}// satisfies checks the value against a type but keeps the narrow inferred type
const config = {
retries: 3,
endpoint: '/api/v1'
} satisfies Record<string, string | number>;
config.retries.toFixed(0); // number, not string | number
// config.reteries; // error: the key does not exist
// an annotation would widen it instead
const widened: Record<string, string | number> = config;
// widened.retries.toFixed(0); // error: no such property on the wide type- A predicate is an unchecked assertion: the compiler takes your word for it, so keep the check beside the type it claims to prove.
asserts value is Tnarrows the rest of the enclosing scope when the function returns normally, and throws otherwise.- Guard the union at the edge - parsed JSON, form data, an event payload - then work with the narrowed type everywhere inside.
Exhaustiveness with never
function assertNever(value: never): never {
throw new Error('unhandled case: ' + JSON.stringify(value));
}
type Event =
| { type: 'open' }
| { type: 'close'; reason: string }
| { type: 'resize'; width: number; height: number };
function handle(event: Event): string {
switch (event.type) {
case 'open':
return 'opened';
case 'close':
return 'closed: ' + event.reason;
case 'resize':
return event.width + 'x' + event.height;
default:
return assertNever(event); // a new variant breaks the build right here
}
}
// the same idea as a lookup keyed by the discriminant
type Handlers = { [K in Event['type']]: (event: Extract<Event, { type: K }>) => string };
const handlers: Handlers = {
open: () => 'opened',
close: event => 'closed: ' + event.reason,
resize: event => event.width + 'x' + event.height
};
function handle2(event: Event): string {
return handlers[event.type](event as never);
}- Give every variant a literal discriminant and make it the first property.
- Switch on the discriminant so each branch sees exactly one payload.
- Add a default that calls
assertNever, so a new variant fails compilation instead of falling through. - Keep the union in one exported place, so every consumer is rechecked when it grows.
⚠️
Exhaustiveness only works when the branch really covers the whole union. A default that returns a sensible fallback instead of calling
assertNever turns a compile error into a silent wrong answer - the exact failure the pattern exists to prevent.FAQ
Why does narrowing stop working inside a callback?
Narrowing is flow analysis over the current scope, and a callback may run later, after the value could have changed. Copy the narrowed value into a
const first, or narrow again inside the callback.What does satisfies actually do?
It verifies the value against the type and keeps the value's own narrower type. An annotation does the opposite: it widens the value to the declared type and loses the literal keys you could otherwise use.
Related
Real-world modelling: errors, API and state Typing functions, callbacks and this
Last refreshed 2026-09-18.