Exhaustive Checks in TypeScript
Exhaustiveness checking makes the compiler catch unhandled union variants. Learn the never-based pattern, the assertNever helper, and how to guarantee every case is covered.
Exhaustiveness checking guarantees that every variant of a discriminated union is handled. When you add the never type to the default branch of a switch statement, TypeScript produces a compile error if any variant slips through unhandled.
This turns a bug that would otherwise only show up at runtime into a compile-time error you catch before shipping.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number };Add a default branch that assigns the remaining value to a variable typed never, which only compiles if every other case has already been handled:
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _check: never = shape;
return _check;
}
}The const _check: never = shape line is the exhaustiveness check. Since both variants are handled, shape has type never in the default branch, and the assignment compiles. If a variant is missing, it does not.
Why Exhaustiveness Checking Exists
Discriminated unions grow. A network state goes from loading | success | error to include cached, or an event type adds a new timeout case.
Without exhaustiveness checking, the new variant silently falls through to the default branch, and nothing warns you that it is unhandled.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number }
| { kind: "triangle"; base: number; height: number };The existing getArea function from above was never updated for the new triangle variant, so it still only handles two cases:
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _check: never = shape;
return _check;
}
}The exhaustiveness check catches the gap immediately, because a triangle shape reaches the default branch where only never is expected:
Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.The error names the unhandled variant directly. You add a triangle case, the error disappears, and your code handles every state.
The assertNever Helper
Repeating the same never assignment in every switch gets tedious. Extract the check into a reusable helper instead:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}Call the helper from every default branch across the codebase, so every exhaustiveness check reports the same clear error shape:
function getShapeName(shape: Shape): string {
switch (shape.kind) {
case "circle": return "Circle";
case "square": return "Square";
case "triangle": return "Triangle";
default: return assertNever(shape);
}
}The assertNever helper does two things: it satisfies the compiler at compile time, and it throws a clear error at runtime if the check ever fails. Its never return type also satisfies the enclosing function's own return type requirement.
Two Layers of Safety
Combine assertNever with an explicit function return type for double protection:
type Status =
| { state: "idle" }
| { state: "loading" }
| { state: "done"; result: string };Declaring the return type as string gives TypeScript a second signal to check against, on top of the never assignment inside the helper:
function render(status: Status): string {
switch (status.state) {
case "idle": return "Waiting...";
case "loading": return "Loading...";
case "done": return `Result: ${status.result}`;
default: return assertNever(status);
}
}If a case is missing, the never assignment fails. If the switch has no return in some branch, the explicit string return type catches it. Both errors are compile-time.
The cycle is: write the switch, let the compiler find gaps, add the missing case, repeat until clean.
Exhaustiveness Without a Discriminated Union
You can use exhaustiveness checking on any union, not just discriminated ones:
function describe(value: string | number | boolean): string {
if (typeof value === "string") return `String: ${value}`;
if (typeof value === "number") return `Number: ${value}`;
if (typeof value === "boolean") return `Boolean: ${value}`;
const _check: never = value; // Compiles — all types handled
return _check;
}TypeScript tracks which types remain through control flow. When none remain, the value is never and the assignment compiles.
When Exhaustiveness Checking Fails
Missing the return type annotation. Without an explicit return type on the function, TypeScript does not enforce that every path returns. A missing case silently returns undefined.
// No return type — missing case silently returns undefined
function render(status: Status) {
switch (status.state) {
case "idle": return "Waiting...";
case "loading": return "Loading...";
// Missing "done", no error without exhaustiveness check
}
}Always add a return type and the assertNever helper together.
Using a non-literal discriminant. If the discriminant is typed as a plain string instead of a literal like "idle", narrowing does not work and exhaustiveness checking cannot help.
Forgetting the default branch. Without a default branch, TypeScript cannot perform the never check. Always include it, even if you think you have handled everything.
Exhaustiveness vs if/else Chains
A switch statement with assertNever is the standard pattern, but the same never check also works at the end of an if/else chain:
| Approach | Readability | Exhaustiveness check |
|---|---|---|
| switch with default | Clear for many variants | never assignment in default |
| if/else chain | Clear for two or three variants | never assignment after the last if |
function handle(shape: Shape): number {
if (shape.kind === "circle") return Math.PI * shape.radius ** 2;
if (shape.kind === "square") return shape.sideLength ** 2;
if (shape.kind === "triangle") return (shape.base * shape.height) / 2;
const _check: never = shape;
return _check;
}The switch version scales better as variants are added, but both patterns get the same compile-time guarantee from the never assignment.
Common Mistakes
Only using assertNever without a return type. The function can still return undefined if you forget a return statement inside a case. Use both together.
Placing assertNever before handling all cases. The helper belongs in the default branch only. Putting it earlier causes false errors because TypeScript sees the union is not yet exhausted.
Not updating the union when adding a variant. The whole point of exhaustiveness checking is that adding a variant creates a compile error. Do not silence it with an as any cast. Add the missing case instead.
Forgetting that never is the bottom type. The never type is assignable to everything, but nothing except never is assignable to it. This is why the assignment check works.
Exhaustiveness checking works best with discriminated unions. For modeling states that benefit from this pattern, see model loading, success, and error states with unions.
Rune AI
Key Insights
- Exhaustiveness checking guarantees every variant of a discriminated union is handled.
- Use the never type: assign the remaining value to never in the default case.
- The assertNever helper produces a clear error with the unhandled variant name.
- When you add a new variant, the compiler tells you exactly which case is missing.
- Combine with a return type annotation on the function for an extra layer of safety.
Frequently Asked Questions
What is exhaustiveness checking in TypeScript?
How does the assertNever function work?
Do I always need exhaustiveness checking?
Conclusion
Exhaustiveness checking turns a runtime oversight into a compile-time error. Add a default case with assertNever to every switch on a discriminated union, and TypeScript will tell you whenever a variant is missing. It is a small investment that pays off every time the union grows.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.