How to Ensure TypeScript Switch Case Statements Are Exhaustive: A Complete Guide
Use the never type with an assertNever helper function in the default case of your TypeScript switch statement to enforce compile-time exhaustiveness checking and prevent runtime errors.
TypeScript switch case exhaustiveness is a critical pattern for maintaining type-safe applications, especially when working with discriminated unions. According to the Microsoft TypeScript repository source code, the compiler itself relies on the never type to enforce exhaustive checks internally, making this technique a first-class best practice for developers.
Why Exhaustive TypeScript Switch Case Statements Matter
When working with discriminated unions—types where each member has a common literal-typed property like kind or type—you need to handle every variant to prevent runtime errors. Without exhaustiveness checking, adding a new member to a union won't trigger errors in existing switch statements, leading to undefined behavior in the default case.
The TypeScript compiler addresses this through control-flow analysis. In src/compiler/checker.ts, the function computeExhaustiveSwitchStatement (around line 39361) marks a switch as exhaustive when all cases are covered and no default clause exists. The compiler then stores this state in the isExhaustive flag (line 39363) to treat the default branch as unreachable during later analysis.
The never Type Pattern for Exhaustiveness Checking
The most reliable method to enforce exhaustive TypeScript switch case statements is the assertNever pattern. This technique leverages TypeScript's ability to detect when code is reachable that should be impossible.
How assertNever Works
The pattern relies on a helper function that accepts only the never type:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
When you place this in the default case of a switch statement handling a discriminated union, TypeScript checks whether all possible values have been handled. If any case is missing, the value in the default branch won't be typed as never, causing a compile-time error when you try to pass it to assertNever.
Compiler Implementation Details
The TypeScript compiler itself uses this pattern extensively. In src/compiler/utilities.ts (around line 2963), the compiler provides the assertNever helper used throughout the codebase. The parser in src/compiler/parser.ts (line 2941) uses Debug.assertNever for non-exhaustive parser cases, demonstrating how the TypeScript team treats this as a critical safety mechanism.
Additionally, the binder in src/compiler/binder.ts (line 1727) detects potentially exhaustive switches when no default clause is present, working in conjunction with the checker's exhaustiveness computation.
Practical Implementation: Discriminated Unions and Switch Cases
Here's a complete example demonstrating exhaustive TypeScript switch case handling with a discriminated union:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "square":
return s.side ** 2;
case "triangle":
return (s.base * s.height) / 2;
default:
// Compile-time error if a new Shape variant is added
return assertNever(s);
}
}
If you later add { kind: "rectangle"; width: number; height: number } to the Shape union, TypeScript will immediately flag the assertNever(s) call as an error because s is no longer of type never.
You can also enforce exhaustiveness without a default clause by enabling noImplicitReturns in your tsconfig.json:
function description(s: Shape): string {
switch (s.kind) {
case "circle":
return `Circle with radius ${s.radius}`;
case "square":
return `Square with side ${s.side}`;
case "triangle":
return `Triangle with base ${s.base} and height ${s.height}`;
} // Error: not all code paths return a value (TS7030)
}
Best Practices for Exhaustive TypeScript Switch Statements
Follow these guidelines to maintain type safety in your applications:
-
Use discriminated unions – Ensure every member has a literal-typed property (commonly
kindortype) that TypeScript can track. -
Enable strict compiler options – Turn on
strict,noImplicitReturns, andstrictNullChecksto surface incomplete branches during compilation. -
Add an
assertNeverdefault – Even if you believe all cases are covered, include a default clause that callsassertNever(value)to guarantee compile-time checking. -
Prefer
never-returning functions – Functions that should never be called (such as unreachable code handlers) should explicitly returnnever. -
Leverage
as const– When creating literal objects, useas constto preserve literal types so the compiler can discriminate them effectively.
Common Pitfalls to Avoid
Watch out for these mistakes that undermine exhaustiveness checking:
-
Missing discriminant property – Forgetting the discriminant property or giving it a non-literal type (like
stringinstead of"A" | "B") causes the union to lose exhaustiveness information. -
Using
anyorunknown– Switching on values typed asanyorunknowndisables exhaustiveness checking because the compiler cannot narrow the types. -
Empty default clauses – Adding a
defaultclause without anassertNevercall silently allows missing cases, defeating the purpose of compile-time verification. -
Mutating the switch value – Reassigning the discriminant property within the switch statement can confuse the control-flow analyzer.
Summary
-
Use the
nevertype with anassertNeverhelper function to enforce exhaustive TypeScript switch case statements at compile time. -
Rely on discriminated unions with literal-typed properties to enable the compiler to track and narrow types effectively.
-
Reference the TypeScript compiler's own implementation in
src/compiler/checker.ts(specificallycomputeExhaustiveSwitchStatementand theisExhaustiveflag) to understand how exhaustiveness is computed and enforced. -
Enable strict compiler options like
noImplicitReturnsto catch missing cases even without an explicit default clause. -
Avoid common pitfalls such as using
anytypes, empty default clauses, or non-literal discriminant properties that disable exhaustiveness checking.
Frequently Asked Questions
What is an exhaustive switch statement in TypeScript?
An exhaustive switch statement in TypeScript is one that handles every possible value of a discriminated union type. When a switch is exhaustive, the TypeScript compiler knows that all cases have been accounted for, allowing it to narrow types effectively and prevent runtime errors. The compiler marks switches as exhaustive using the computeExhaustiveSwitchStatement function in src/compiler/checker.ts, which sets an isExhaustive flag when all cases are covered.
How does the never type enforce exhaustiveness?
The never type represents values that should never occur. When you create an assertNever function that accepts only never and place it in the default case of a switch statement, TypeScript checks whether the default branch is reachable. If you've handled all union members, the remaining type is never and the code compiles. If you miss a case, the default branch receives a concrete type instead of never, causing a compile-time error when passed to assertNever. This pattern is used throughout the TypeScript compiler itself, such as in src/compiler/utilities.ts and src/compiler/parser.ts.
Where is exhaustiveness checking implemented in the TypeScript compiler?
Exhaustiveness checking is primarily implemented in src/compiler/checker.ts. The function computeExhaustiveSwitchStatement (around line 39361) analyzes switch statements to determine if they cover all possible values of a union type. When exhaustive, it marks the switch with an isExhaustive flag (line 39363). The binder in src/compiler/binder.ts (line 1727) also participates by detecting potentially exhaustive switches during the binding phase. These mechanisms work together to enable control-flow narrowing and unreachable code detection.
Should I always include a default case in exhaustive switch statements?
While TypeScript can detect exhaustive switches without a default clause when noImplicitReturns is enabled, including a default case with an assertNever call is the safest practice. The default case serves as a compile-time assertion that will break the build if a new union member is added later. Without it, you might accidentally fall through or return undefined, causing runtime errors. The TypeScript compiler itself uses this defensive approach in areas like the parser (src/compiler/parser.ts), ensuring that every possible AST node type is explicitly handled.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →