Exhaustive Pattern Matching in TypeScript with Discriminated Unions and the never Type
The Problem: Incomplete Switches
Every TypeScript developer has been bitten by a switch statement that silently ignores a new enum value or union member. The compiler happily compiles, but at runtime the default case fires — or worse, nothing happens — and bugs surface in production. I’ve seen this bite teams when a new WebSocket message type is added but the handler isn’t updated, causing messages to be dropped without a trace.
The Trick: Discriminated Unions + never
TypeScript’s discriminated unions give each variant a literal tag (often called kind or type). When you narrow the union inside a switch, the compiler knows exactly which members remain. By assigning the narrowed value to a variable of type never in the default branch, you force the type checker to verify that every possible case has been handled. If a new variant appears later, the never assignment becomes an error, alerting you instantly.
type Message =
| { kind: 'ping'; payload: number }
| { kind: 'pong'; payload: number }
| { kind: 'error'; code: string; reason: string };
function handle(msg: Message): void {
switch (msg.kind) {
case 'ping':
console.log('Ping received', msg.payload);
break;
case 'pong':
console.log('Pong received', msg.payload);
break;
case 'error':
console.error('Error', msg.code, msg.reason);
break;
default:
// Exhaustiveness check — will error if a new kind is added
const _exhaustive: never = msg;
return _exhaustive;
}
}
Real‑World Scenario: Handling WebSocket Messages
In a recent project we built a real‑time dashboard that receives a stream of messages from a server. The message schema evolves as new features ship — telemetry, alerts, config updates. Using the pattern above, every time the backend adds a new kind the frontend compile step fails until the handler is updated. This eliminated an entire class of “message silently ignored” bugs that used to surface only during QA.
// Server‑side adds a new telemetry type
// type Message = ... | { kind: 'telemetry'; metrics: Metric[] };
function onMessage(raw: unknown) {
const msg = raw as Message; // validated elsewhere
handle(msg); // compile‑time guarantee we cover all kinds
}
Why It Works
The never type represents a value that can never exist. After the switch has narrowed msg through all known cases, the only way to reach default is if msg holds a type that TypeScript doesn’t know about — i.e., a future union member. Assigning it to never creates a type error because the inferred type of msg in that branch is the *remaining* union members, which is not assignable to never. This turns a runtime omission into a compile‑time error.
Key insight: The exhaustiveness check is free — no extra runtime code, just a compile‑time safety net.
Pitfalls and Tips
- Always use a literal discriminant (string, number, or symbol). Structural typing without a tag won’t narrow correctly.
- Prefer
switchover chainedif/elsefor the clearest narrowing. - If you need to return a value from each case, make the function return a union and let the
defaultbranch returnneveras well. - When working with third‑party types you can’t modify, create a local discriminated union that mirrors the external shape and map incoming data into it.
Adopting this pattern has saved me countless debugging sessions. It turns the type system into an active participant in API evolution rather than a passive annotator. Next time you add a variant to a union, let the compiler remind you where the handling lives — you’ll thank yourself later.