Exhaustiveness Checking (never)
Exhaustiveness Checking (never)
Level 6 — Type Narrowing & Guards A pattern that leverages the
nevertype to force the compiler to verify that all possible members of a Union type have been explicitly handled in control flow blocks (likeswitchorif/else).
1. Prerequisites
- Discriminated Unions — Objects with a shared literal tag.
void&never— The type representing unreachable states.
2. Term Category
Type System Fundamental (Compile-Time Exhaustiveness Enforcement): Exhaustiveness checking assigns unhandled union variants to never in switch defaults to ensure all possibilities are explicitly handled.
3. Explanation
Environment Context
- Build-time (The verification checks occur during compilation, translating to safety exceptions at runtime if checks fail).
(1) Design Motivation — "Why did we design this?"
When writing applications, you often write code that branches based on a type category—such as resolving payment options ('card' | 'paypal'), processing user permissions ('admin' | 'editor'), or handling action types in state reducers.
Typically, you write a switch statement to handle each case. However, as applications grow, team members add new options to these types (for example, adding 'apple-pay' to payment options).
If you forget to find and update every single switch statement in the codebase to handle this new type, the application will silently skip the new case, resulting in runtime errors, empty screens, or corrupt database states.
Exhaustiveness Checking provides a compile-time safeguard. It turns a silent runtime omission into an immediate build-time error, forcing developers to implement handling for the new option before they can build the code.
(2) Core Mechanics
Exhaustiveness checking utilizes the fact that TypeScript narrows down union members as you check them.
If you have a variable representing a union of Circle | Square, and you handle the Circle case in one branch and the Square case in another, there are no possible types left. If you open a default or else block, the type of the variable is narrowed to never (since it can literally never be anything else).
We can write a utility function that accepts only never:
function assertUnreachable(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
If we pass our variable into assertUnreachable(val) inside the default block:
- If all cases are handled: the variable is
never, the call compiles. - If a case is missing (e.g.
Triangle): the variable is typed asTriangleinside thedefaultblock. The compiler throws an error because you cannot passTriangleto a function expectingnever!
graph TD
A[Union Type: A | B | C] --> B{Switch Case}
B -- case A --> C[Handle A]
B -- case B --> D[Handle B]
B -- default --> E{Is C handled?}
E -- Yes --> F[Variable is never - OK]
E -- No --> G[Variable is C - Compile Error!]
(3) Code Examples
Short Snippet
interface Circle { kind: 'circle'; radius: number; }
interface Square { kind: 'square'; side: number; }
// Add a new shape to the union
interface Triangle { kind: 'triangle'; base: number; height: number; }
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
// Bug: Triangle is unhandled!
default:
// Error: Argument of type 'Triangle' is not assignable to parameter of type 'never'
return assertUnreachable(shape);
}
}
4. Common Mistakes & Pitfalls
Mistake 1: Relying on standard default blocks without never validation
The mistake: Adding a generic default fallback (e.g. return 0 or doing nothing) instead of executing an exhaustiveness assertion.
Why it's wrong: While it satisfies the compiler, it hides omissions. When a new union option is added, it will silently trigger the fallback instead of warning the developer that code must be written for the new case.
Incorrect:
type Status = 'success' | 'pending' | 'failed';
function notifyUser(status: Status) {
switch (status) {
case 'success': sendEmail('Done'); break;
case 'failed': sendEmail('Error'); break;
default:
// If 'pending' is introduced, we do nothing. No build warning!
break;
}
}
Fix: Add the assertion to force type safety when status types are updated.
function notifyUser(status: Status) {
switch (status) {
case 'success': sendEmail('Done'); break;
case 'failed': sendEmail('Error'); break;
case 'pending': sendEmail('Loading'); break;
default:
assertUnreachable(status); // Safe compile guard
}
}
Golden Rule: Always terminate dynamic union matching conditions (switches, if-else structures) with an explicit never assertion check inside the final fallback.
Mistake 2: Omitting Exhaustiveness Checks when Adding New Variants to Unions
The mistake: Adding | { kind: "triangle" } to Shape union without default never checking in area() function.
Why it's wrong: Without an exhaustiveness check, unhandled union variants pass quietly during compilation, returning undefined at runtime.
Incorrect:
type Action = { type: "login" } | { type: "logout" } | { type: "register" };
function handle(a: Action) {
if (a.type === "login") return;
if (a.type === "logout") return;
// Missing register! Returns undefined silently.
}
Fix:
function handle(a: Action) {
switch(a.type) {
case "login": return;
case "logout": return;
case "register": return;
default:
const _check: never = a; // ❌ Fails compilation if new Action variants are added!
return _check;
}
}
Mistake 3: Returning Default Fallback Values in Place of Exhaustive never Checks
The mistake: Returning return null in default switch cases, hiding unhandled union variants.
Why it's wrong: Returning fallback values masks unhandled variants instead of flagging missing logic at build time.
Incorrect:
switch(shape.kind) {
case "circle": return 1;
default: return 0; // Masks missing square variant!
}
Fix:
switch(shape.kind) {
case "circle": return 1;
case "square": return 2;
default:
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
5. Practice Exercises
Exercise 1: Enforcing Exhaustiveness Checking with never
Scenario:
Enforce compile-time exhaustiveness checking on a PaymentMethod union ("credit_card" | "paypal" | "crypto").
Requirements:
- Assign unhandled default to
nevervariable.
Answer
Implementation
type PaymentMethod = "credit_card" | "paypal" | "crypto";
function processPayment(method: PaymentMethod) {
switch (method) {
case "credit_card":
return "Processing Card...";
case "paypal":
return "Redirecting to PayPal...";
case "crypto":
return "Awaiting Blockchain Confirmations...";
default:
// Exhaustiveness check: fails compilation if any payment method is unhandled!
const _exhaustiveCheck: never = method;
return _exhaustiveCheck;
}
}
Technical Explanation
- Assigning
methodto anevervariable indefault:causes a compile error if any union member is omitted. - If a new payment method (e.g.
"apple_pay") is added toPaymentMethodlater,tschighlights all unhandledswitchstatements instantly. - Crucial technique for refactoring large discriminated union codebases safely.
Exercise 2: Helper Functions for Unreachable Code
Scenario:
Create a reusable assertNever(x: never): never utility function for exhaustiveness checking.
Requirements:
- Export
function assertNever(x: never): never.
Answer
Implementation
export function assertNever(x: never): never {
throw new Error(`Unexpected object in exhaustive check: ${JSON.stringify(x)}`);
}
type Role = "admin" | "user";
function getPermissions(role: Role) {
switch (role) {
case "admin":
return ["read", "write", "delete"];
case "user":
return ["read"];
default:
return assertNever(role);
}
}
Technical Explanation
assertNevercombines static compile-time checking (x: never) with runtime exception safety (throw Error).- Reusable helper across the entire application for union exhaustiveness.
- Standard functional utility pattern.
Exercise 3: Auditing Missing Switch Case Warnings
Scenario:
Demonstrate the compile error triggered when adding a new variant to a union without updating exhaustive switch statements.
Requirements:
- Show compile error on
_exhaustiveCheck.
Answer
Implementation
type Transport = "bus" | "train" | "plane"; // Added 'plane'!
function getTicketPrice(t: Transport): number {
switch (t) {
case "bus": return 2.5;
case "train": return 15.0;
default:
// ❌ Compile Error: Type 'string' (plane) is not assignable to type 'never'.
const _check: never = t;
return _check;
}
}
Technical Explanation
- When
"plane"is added toTransport,tindefault:has type"plane"instead ofnever. - The assignment
const _check: never = tfails immediately at compile time. - Guarantees 100% code branch coverage across union additions.
6. Related Terms
- Discriminated Unions — The type format that exhaustiveness checks protect.
void&never— The structural types representing emptiness.- Type Narrowing — The process of reducing union types.
7. Key Takeaways
- Exhaustiveness Checking triggers build-time warnings if you omit cases when matching union members.
- Utilizes type narrowing: if all cases are matched, the remaining types evaluate to
never. - Triggered by assigning the default state to a
nevervariable or passing it toassertUnreachable(value: never). - Critical for maintaining large codebases, preventing silent bugs when union structures expand.
- Essential for state machines, reducers, and transaction processors.