TypeScript Discriminated Unions: Exhaustive Pattern Matching
A practical guide to TypeScript discriminated unions with exhaustive pattern matching, the never type, and real-world detection patterns for your codebase.
Discriminated unions are TypeScript’s answer to sum types — they let you model data that can be one of several shapes, with the compiler narrowing types automatically. This guide covers the basic pattern, the never exhaustiveness check, edge cases with optional fields and primitive unions, and how to enforce exhaustive matching across your codebase.
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 This Means for Practitioners
-
Adopt the
neverexhaustiveness check as a team standard — everyswitchon a discriminated union should have adefault: const _exhaustive: never = val;branch. Enforce this with a lint rule (TypeScript-ESLint’sswitch-exhaustiveness-check). Without it, adding a new variant silently returnsundefinedat runtime — the compiler won’t catch it. -
Use
satisfies(TS 4.9+) to validate handler maps — instead ofswitchstatements, you can define handler objects:const handlers = { ... } satisfies Record<Shape['kind'], Handler>. TypeScript errors if any variant is missing a handler. This pattern is cleaner for medium-to-large unions (5+ variants) and catches missing handlers at compile time. -
Add
tsc --noEmitto CI if you haven’t already — theneverexhaustiveness check produces aType '...' is not assignable to type 'never'error thattscsurfaces. But this only helps if type-checking runs in CI. Without--noEmitin your build pipeline, these errors are invisible until a developer runstsclocally. -
Watch for generic discriminated unions — the discriminator must be a literal type. If you derive it from a generic parameter, the compiler can’t narrow correctly. Use a helper type that maps each variant to its discriminator, or avoid generics on the discriminant field.
-
Check optional fields inside narrowed branches —
y?: numberbecomesnumber | undefinedinside the narrowed type. Use??(not||) for defaults to avoid replacing valid falsy values like0.
How to Detect This in Your Code
grep for Missing never Checks
# Find switch statements on discriminated unions missing the default/never pattern
grep -rn "switch.*\.\(kind\|type\|status\)" src/**/*.ts
# Then manually check: does the switch have a `default: const _exhaustive: never = ...`?
# Find handler maps (objects) that might miss variants
grep -rn "satisfies Record" src/**/*.ts
# These are already safe — add them where they're missing
# Find any if/else chains that narrow on a union discriminator without a never check
grep -rn "typeof.*===.*'string'" src/**/*.ts | head -10
Detection Checklist
| Pattern | Risk | Fix |
|---|---|---|
switch on enum/union | Missing variant = silent undefined return | Add default: const _exhaustive: never = val; |
if/else chain on typeof | New type added = last branch silently widens | Add final else with _exhaustive: never check |
Handler object (no satisfies) | New variant = handler returns undefined | Add satisfies Record<UnionType['kind'], Handler> |
No --noEmit in CI | Unused variables, broken types ship to prod | Add tsc --noEmit to CI pipeline |
Quick Fix: Migrate a Switch Without never
Before:
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
// If 'triangle' is added, no compile error — returns undefined
}
}
After:
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
default:
const _exhaustive: never = shape; // 🔴 TypeScript error if variant missing
return _exhaustive;
}
}
Common Codebase Patterns to Watch
- Redux reducers: Most Redux reducers use
switch (action.type)— these are discriminated unions. Add adefault: const _exhaustive: never = action;to catch new action types at compile time. - React component variants:
switch (props.variant)orprops.status— a new variant silently falls through to the default render path. - API response handlers:
switch (response.status)— adding a new status code doesn’t error unless there’s anevercheck.
See TypeScript Handbook: Discriminated Unions, TypeScript Handbook: The never type, and TypeScript 4.9: satisfies operator for official references.
Quick Start Checklist
Adding discriminated unions with exhaustive matching to a new or existing TypeScript project:
-
Define the union type — start with a literal
kind,type, orstatusdiscriminator field. Each variant must have a unique literal value for the discriminator. Example:type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number } -
Write the handler function — use a
switchon the discriminator field. TypeScript narrows the type inside eachcaseblock automatically. No manual type assertions needed. -
Add the
neverexhaustiveness check — in thedefaultbranch, assign the narrowed variable to anever-typed variable:const _exhaustive: never = shape;. This catches new variants at compile time. -
Validate handler maps with
satisfies— if you prefer a handler-map pattern (object mapping discriminators to functions), wrap it:const handlers = { ... } satisfies Record<Shape['kind'], Handler>. TypeScript errors if any variant is missing a handler. -
Watch optional fields — inside narrowed branches, optional fields become
T | undefined. Use??(not||) for defaults to avoid replacing valid falsy values like0. -
Run
tsc --noEmit— a new variant without a handler produces aType '...' is not assignable to type 'never'error pointing to the exact_exhaustiveline. Fix by adding a newcaseblock.
Key Takeaways
- Always add a
neverexhaustiveness check in thedefaultbranch —const _exhaustive: never = shape;turns a missed variant into a compile-time error. Without it, adding a new variant silently produces undefined behavior at runtime. This is the single highest-ROI pattern in this post. - Use
neverin bothswitchandif/elsechains — the pattern works with both control flow structures. The lastelsebranch assigns the narrowed type to anevervariable. It does NOT work in ternary chains (type narrowing doesn’t propagate across ternary branches the same way). - Use the
satisfiesoperator (TS 4.9+) to validate handler maps — instead of manually checking that every variant has a handler, define your handlers as a record keyed by the discriminator:const handlers = { ... } satisfies Record<Shape['kind'], Handler>. TypeScript errors if any variant is missing. - Watch for optional fields in discriminated unions —
y?: numberbecomesnumber | undefinedinside the narrowed type. Use??(not||) to provide defaults, since||would replace valid falsy values like0. - Generic discriminated unions need careful design — the discriminator must be a literal type, which means you can’t derive it from a generic parameter without type mapping. If you need generic discriminated unions, consider using a helper type that maps each variant to its discriminator.
|`