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.
Watch Out
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.
Avoid When
- When a union type has a catch-all member (like a generic `string` or `unknown`) that legitimately handles multiple cases without explicit handlers.
- When you intentionally want to ignore new union members and let them fall through to a default behavior without breaking the build.
- When the union is external or third-party and you cannot control its evolution, making exhaustive checks a maintenance burden rather than a safety net.
- When performance is critical and the overhead of exhaustive-check assignments on hot code paths outweighs the safety benefit.
When To Use
- You have a union type representing distinct states or variants (e.g. API responses, UI modes, payment statuses) that may grow over time and every handler must stay in sync.
- Your switch or if/else chain handles user-facing logic where missing a case silently falls through to undefined behavior instead of erroring loudly.
- You're refactoring a union type and need TypeScript to force you to audit every place that pattern-matches on it, rather than relying on manual review.
- Team members regularly add new variants to shared union types without coordinating with all consuming code, and you want compile-time enforcement instead of runtime bugs.
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
}