You refactored a shape type, added a new variant, and TypeScript stayed green. Your CI passed. Your deploy went out. Then a user hit a runtime branch that returned undefined, and your app threw in production.
The culprit was almost certainly a switch statement. TypeScript’s switch has no exhaustiveness guarantee. Add a new member to a union, and every switch over that union becomes silently incomplete. The compiler won’t stop you. It won’t even warn you. This is the part that trips people up: TypeScript knows the full set of possibilities, but switch statements don’t participate in exhaustiveness checking.
What exhaustiveness checking actually means
Exhaustiveness means the compiler can prove that every possible value has been handled. Languages like Rust and OCaml enforce this at compile time. TypeScript doesn’t, not for switch statements.
Consider a discriminated union:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
// triangle is missing — and TypeScript is fine with it
}
}
This compiles. It also returns undefined when shape.kind === "triangle". The function signature promises a number, but the implementation doesn’t deliver one. TypeScript doesn’t flag the missing case because switch blocks aren’t checked for completeness.
The object lookup pattern
The simplest replacement is a plain object map. Every key maps to a handler function. You index into the object with the discriminant, and the compiler checks the key exists.
const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
rectangle: (s) =>
(s as Extract<Shape, { kind: "rectangle" }>).width *
(s as Extract<Shape, { kind: "rectangle" }>).height,
triangle: (s) =>
0.5 *
(s as Extract<Shape, { kind: "triangle" }>).base *
(s as Extract<Shape, { kind: "triangle" }>).height,
};
function area(shape: Shape): number {
return areaHandlers[shape.kind](shape);
}
This is better. If you add "polygon" to Shape, TypeScript will complain that areaHandlers is missing a key. The error points at the right place, and it happens at compile time.
But the casts are ugly. We can do better.
Type-safe handlers without casting
The trick is to map each variant to a function that receives the narrowed type directly. You use a mapped type to preserve the relationship between the key and the specific shape variant.
type ShapeKind = Shape["kind"];
type AreaMap = {
[K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};
const areaHandlers: AreaMap = {
circle: (s) => Math.PI * s.radius ** 2,
rectangle: (s) => s.width * s.height,
triangle: (s) => 0.5 * s.base * s.height,
};
function area(shape: Shape): number {
const handler = areaHandlers[shape.kind];
return handler(shape as never);
}
Now each handler receives a properly narrowed argument. No manual casts inside the functions. The as never on the last line is a small wart, but it’s localized and safe. The real value is that areaHandlers must contain every key in ShapeKind. Remove one, or add a new variant to Shape, and the build breaks.
Exhaustiveness checking with a never assert
Some teams prefer to keep the switch but add a compile-time exhaustiveness guard. This is a pragmatic middle ground. You add a default branch that takes never, forcing the compiler to verify that all cases were handled.
function assertNever(x: never): never {
throw new Error("Unexpected value: " + x);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
If you add a new shape kind and forget to add a case, the default branch receives a non-never value. TypeScript errors because you can’t assign a concrete type to never. The error points at the switch, which is exactly where you want it.
This pattern is widely used at Sentry and other large TypeScript codebases. It doesn’t require changing the structure of your code. It just makes the compiler work for you instead of against you.
Pattern matching with third-party libraries
If you want something closer to Rust’s match, libraries like ts-pattern provide pattern matching with exhaustiveness checking built in.
import { match, P } from "ts-pattern";
function area(shape: Shape): number {
return match(shape)
.with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
.with({ kind: "rectangle" }, (s) => s.width * s.height)
.with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
.exhaustive();
}
The .exhaustive() call is the key. If a variant is missing, TypeScript reports an error. The library also supports nested patterns, guards, and wildcards, which can replace complex nested switch blocks.
This is powerful, but it adds a dependency. For a single discriminated union, ts-pattern is overkill. For codebases with heavy pattern matching, it’s worth the bundle size.
Where switch statements still win
Switch isn’t always wrong. If your cases need to fall through, or if the logic is imperative with early returns and side effects, a switch can be more readable than a lookup table or a chain of .with() calls.
The problem isn’t the syntax. It’s the lack of exhaustiveness checking. If you’re using switch, add the assertNever guard. It’s two lines of code and it closes the safety gap.
How to adopt this without rewriting everything
You don’t need to refactor all your switches today. Here’s a practical order of operations:
- Add
assertNeverto your shared utilities. Use it in new switch blocks. - When you touch existing switches over unions, add the guard then.
- For new code over discriminated unions, prefer the object-map pattern if the logic is pure and each case is independent.
- Evaluate
ts-patternif you find yourself writing nested switches or complex matching logic more than a few times.
The goal isn’t to eliminate switch statements. It’s to make the compiler catch the bug before your users do.
If you want to see this in action, try deleting one handler from the AreaMap example and watch tsc complain. That red squiggle is the difference between a quiet Friday deploy and a weekend pagerduty page.