Exhaustive Checks with never
debt(d1/e1/b3/t3)
Closest to 'caught instantly (compiler/syntax error)' (d1). When assertNever is correctly placed in a switch default and a new union variant is added without handling it, TypeScript emits a compile-time type error immediately — no specialist tool needed, no runtime required.
Closest to 'one-line patch or single-call swap' (e1). The quick_fix confirms the remedy is a single reusable helper 'assertNever(x: never): never' dropped into switch defaults — a one-line addition per switch case.
Closest to 'localised tax' (b3). The pattern must be applied at every switch/if-else that handles a union, imposing a small per-site discipline. However, it's a lightweight helper with no cross-cutting architectural weight; it doesn't shape the rest of the codebase.
Closest to 'minor surprise (one edge case)' (t3). The misconception field notes that developers wrongly believe never checks only work with discriminated unions, when they work with any narrowable union. The common_mistakes also highlight the subtle issue that assertNever must have a 'never' return type — both are minor but real surprises rather than catastrophic ones.
Also Known As
TL;DR
Explanation
TypeScript narrows union types as control flow progresses through if/switch statements. After all known members are handled, the remaining type is 'never' — the empty type that has no values. Assigning that remaining value to a 'never' variable compiles fine if all cases are covered, but errors if any case was missed (because something non-never would be assigned to a never slot). This is the exhaustive check pattern. It catches the most common union-evolution bug: a new member is added to a union type, but the switch statement handling it is not updated — TypeScript immediately errors at every switch that lacks the new case.
Common Misconception
Why It Matters
Common Mistakes
- Using 'default: break' without a never check — this silently swallows unhandled cases with no compile-time warning.
- Not returning from assertNever — it must be typed as 'never' return type; otherwise TypeScript allows it in the default and the exhaustiveness is lost.
- Forgetting exhaustive checks in if-else chains — switch is the most common place but 'if/else if' on a union also benefits from a trailing else that calls assertNever.
- Using assertNever in runtime-only code where the union may be extended by third parties — exhaustive checks are appropriate for closed unions you control, not open unions.
Code Examples
// ❌ No exhaustive check — new union member silently unhandled
type Status = 'active' | 'inactive' | 'suspended';
function getLabel(status: Status): string {
switch (status) {
case 'active': return 'Active';
case 'inactive': return 'Inactive';
// 'suspended' added to the union — no error here
// Returns undefined at runtime
}
}
// ✅ Exhaustive check — new member forces update
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
type Status = 'active' | 'inactive' | 'suspended';
function getLabel(status: Status): string {
switch (status) {
case 'active': return 'Active';
case 'inactive': return 'Inactive';
case 'suspended': return 'Suspended';
default: return assertNever(status);
// Add 'banned' to Status → compile error here until handled
}
}
// Inline never check (no helper)
switch (action.type) {
case 'A': /* ... */ break;
case 'B': /* ... */ break;
default:
const _: never = action; // Error if any case missing
}