Exhaustiveness Check (never)
If a type slips through, never blows the whistle!
When you handle all members of a union type in a switch or if-else chain, you should have NO remaining cases. But what if you ADD a new member to the union later and forget to update your switch? TypeScript won't warn you — unless you use the exhaustiveness check with never. This is one of the most important patterns in TypeScript for maintaining large codebases safely.
Think of the final RTA checkpoint at the Telangana border. Every vehicle MUST be accounted for — trucks go left, cars go center, bikes go right. The inspector counts: if all vehicles are routed, the lot is empty — that's never, no vehicles remain unaccounted for. But if a NEW vehicle type appears (say, an e-rickshaw) and nobody told the inspector, there's an unhandled vehicle standing at the gate!
The exhaustiveness check makes TypeScript BLOW THE WHISTLE: "Error! Unhandled type!" By assigning the remaining value to never in the default case, TypeScript checks: is this value actually never? If yes — all cases handled, no error. If no — you forgot a case! A compile-time error catches the bug before it reaches production. This pattern is your safety net against forgotten cases in switch statements and if-else chains. Without it, new types silently fall through to the default case, hiding bugs that only surface at runtime.
The core pattern: after handling all known cases, the remaining value should be of type never. If it's not, you've missed a case. Let's build it step by step to understand exactly how it works and why it's so effective.
Basic Pattern:
type Transport =
| "auto"
| "bus"
| "metro";
function getFare(
t: Transport
): number {
switch (t) {
case "auto":
return 30;
case "bus":
return 15;
case "metro":
return 60;
default:
const _exhaustive: never = t;
return _exhaustive;
}
}
Now add "bike" to Transport: type Transport = "auto" | "bus" | "metro" | "bike". The switch doesn't handle "bike" — in the default, t is "bike" (not never!), so const _exhaustive: never = t gives ERROR: "Type 'bike' is not assignable to type 'never'." TypeScript caught the missing case!
The Helper Function Pattern:
function assertNever(
x: never
): never {
throw new Error(
"Unhandled case: " + x
);
}
Usage: default: return assertNever(t); — this is cleaner and also throws a runtime error if somehow reached. The helper function is the idiomatic way to do exhaustiveness checking in TypeScript. You'll see this pattern in virtually every well-written TypeScript codebase that uses discriminated unions extensively.
The most powerful use of exhaustiveness is with discriminated unions. When your union has a common property (the discriminant) with literal types, TypeScript can narrow precisely in each case. Combined with exhaustiveness checking, you get a bulletproof guarantee that every variant is handled.
Shape Example:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
size: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape =
| Circle
| Square
| Triangle;
Area Function (Missing Triangle):
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "square":
return s.size ** 2;
default:
return assertNever(s);
// ERROR! Triangle not handled!
}
}
TypeScript catches it! Add the missing case: case "triangle": return 0.5 * s.base * s.height; — now it compiles cleanly.
With If-Else:
function describe(
s: Shape
): string {
if (s.kind === "circle") {
return "round";
}
if (s.kind === "square") {
return "boxy";
}
// s is never if all handled,
// Triangle if not!
return assertNever(s);
}
Exhaustiveness is a DEVELOPMENT tool — it catches bugs when you ADD new types but forget to update all the handlers. Without it, the default case silently swallows the new type, and you get runtime bugs that are extremely hard to track down in large applications.
Exhaustiveness checking is powerful, but it has traps that can make it ineffective or even break your type safety. Knowing these pitfalls ensures your final checkpoint actually catches the smugglers instead of waving them through!
Trap 1: Forgetting the default case
// WRONG! No exhaustiveness check!
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "square":
return s.size ** 2;
// Missing default/assertNever!
// Adding Triangle won't error!
}
}
Without the default/assertNever, there's no exhaustiveness check. If you add a new type later, TypeScript won't complain. Always add the never check!
Trap 2: Using void instead of never
// WRONG! Return type should be never
function assertNever(
x: never
): void {
throw new Error("unhandled");
}
// RIGHT! never means it never returns
function assertNever(
x: never
): never {
throw new Error("unhandled");
}
never tells TypeScript this function NEVER returns (it throws), which correctly removes the unreachable code path. void means it might return, causing "not all code paths return a value" errors in the calling function.
Trap 3: any in the union
type Bad =
| "auto"
| "bus"
| any; // any absorbs all!
any absorbs everything, so the never check never triggers. The remaining type becomes any, which is assignable to never. Never use any in a union you want to check exhaustively!
Trap 4: Switch fall-through — make sure each case has a break or return. Fall-through can cause unexpected behavior and bypass your carefully constructed type narrowing logic.
Here's your complete cheatsheet for exhaustiveness checking with never. Pin this to your mental whiteboard — it's the ultimate defense against unhandled types in your codebase.
The Pattern: Handle all cases, then assertNever(x) in default.
function assertNever(
x: never
): never {
throw new Error(
"Unhandled: " + x
);
}
Usage in Switch:
switch (value.kind) {
case "a":
return handleA(value);
case "b":
return handleB(value);
default:
return assertNever(value);
}
Usage in If-Else:
if (value.kind === "a") {
return handleA(value);
}
if (value.kind === "b") {
return handleB(value);
}
return assertNever(value);
Works With:
- String literal unions
- Numeric literal unions
- Discriminated unions (most common!)
- Enum unions
Key Rules:
- Always add assertNever in the default/else branch
- The helper must return
never, notvoid - Works by checking that the remaining type is truly
never - Breaks completely if
anyis in the union - Most useful with discriminated unions and literal unions
The Golden Rule: Exhaustiveness is the final RTA checkpoint — every vehicle MUST be accounted for. Add the never check, and TypeScript will blow the whistle the moment you forget a case, bhai!
Key Points
- Exhaustiveness checking ensures all members of a union are handled in switch/if-else
- The assertNever helper catches missing cases at compile time by checking the never type
- Always add assertNever in the default or final else branch
- The assertNever function must return never, not void — it never returns because it throws
- Using any in a union breaks exhaustiveness — any absorbs all types
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login