TypeScript Discriminated Unions: Exhaustive Pattern Matching
Testing my understanding of discriminated unions, type narrowing, and the never type for exhaustive matching in TypeScript.
Discriminated unions are TypeScript’s answer to sum types. I use them daily, but I wanted to verify my understanding across edge cases — especially exhaustive narrowing with never.
The Basic Pattern
type ApiState =
| { status: 'idle' }
| { status: 'loading'; progress: number }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function handleState(state: ApiState): string {
switch (state.status) {
case 'idle':
return 'Waiting to start';
case 'loading':
return `Loading... ${state.progress}%`;
case 'success':
return `Got ${state.data.length} items`;
case 'error':
return `Error: ${state.message}`;
}
}
This compiles because every variant is handled. But what happens when someone adds a new variant?
The never Exhaustiveness Check
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'triangle'; base: 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 'triangle':
return (shape.base * shape.height) / 2;
default:
const _exhaustive: never = shape;
return _exhaustive;
}
}
The default case assigns shape (which should be never when all cases are handled) to a never variable. If a new variant is added without updating the switch, TypeScript errors at _exhaustive, telling you exactly which line to fix.
Edge Case: Union of Primitives
type Result = string | number | boolean;
function formatResult(r: Result): string {
if (typeof r === 'string') return `"${r}"`;
if (typeof r === 'number') return r.toFixed(2);
// typeof r === 'boolean'
return r ? 'true' : 'false';
}
TypeScript correctly narrows through the typeof checks. The final return is inferred as boolean. But there’s a subtle issue: if Result is extended to include bigint, the final return silently accepts boolean | bigint — no error. The never default pattern works here too:
function formatResultSafe(r: Result): string {
if (typeof r === 'string') return `"${r}"`;
if (typeof r === 'number') return r.toFixed(2);
if (typeof r === 'boolean') return r ? 'true' : 'false';
const _exhaustive: never = r;
return _exhaustive;
}
Edge Case: Discriminated Union With Optional Fields
type Event =
| { type: 'click'; x: number; y?: number }
| { type: 'hover'; element: string };
function handleEvent(e: Event) {
if (e.type === 'click') {
// e.y is number | undefined
const yVal = e.y ?? 0;
console.log(`Click at (${e.x}, ${yVal})`);
}
}
The y?: number becomes number | undefined inside the narrowed type. Using ?? is correct, but || would be wrong (falsy 0 would get replaced).
What I Learned
neverexhaustiveness check works inswitchandif/elsechains — but not in ternary chains (type narrowing doesn’t propagate across ternary branches the same way).satisfiesoperator (TS 4.9+) can validate that a value matches a type without changing its inferred type — useful for ensuring a map covers all variants:const handlers = { ... } satisfies Record<Shape['kind'], Handler>.- Discriminated unions with generics get complex fast. Generic discriminated unions require the discriminator to be a literal type, which means you can’t derive it from a generic parameter without type mapping.
Score: 8/10 — strong on basic patterns, need to practice generic discriminated unions more.