Pattern Matching with Switch Statements in TypeScript


Switch Statements and Type Narrowing
A switch statement in plain JavaScript is a convenience — a cleaner way to write a chain of if/else if comparisons. In TypeScript, it's a type narrowing tool. When you switch on the discriminant of a discriminated union, TypeScript narrows the full type inside each case block automatically.
In the last post, you built discriminated unions and used equality checks to narrow them. Switch statements formalise that pattern and add something valuable: a default branch that TypeScript can use to verify you've handled every variant.
Let's start with the shapes example from the previous post and work through increasingly powerful patterns:
type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Rectangle | Triangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
// TypeScript knows: shape is Circle
return Math.PI * shape.radius ** 2;
case "rectangle":
// TypeScript knows: shape is Rectangle
return shape.width * shape.height;
case "triangle":
// TypeScript knows: shape is Triangle
return 0.5 * shape.base * shape.height;
}
}TypeScript narrows shape to the correct variant inside each case. Access shape.radius in the "rectangle" case and you get a compile error. Every property access is checked against the actual type in that branch.
Switch Expressions Are Coming
Many languages have a switch expression — a version that returns a value directly, like a ternary. TypeScript doesn't have one yet (it's a JavaScript proposal in early stages), but you can simulate it with an immediately invoked function or a helper. The pattern is shown in the Pattern Matching Helpers section below.
Exhaustiveness Revisited
Notice that the area function above has no default case. TypeScript is smart enough to know that Circle | Rectangle | Triangle is fully covered by those three cases, so it doesn't complain about a missing return. But there's a fragility: add a fourth shape and the function silently falls through to undefined, which TypeScript might or might not catch depending on your strictNullChecks settings.
The safer approach is to always include a default case that makes the missing-variant problem explicit:
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: {
// If TypeScript reaches here, shape has type never
// If it doesn't, you have an unhandled variant
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
}Now add type Ellipse = { kind: "ellipse"; semiMajor: number; semiMinor: number } to Shape and TypeScript immediately reports: Type 'Ellipse' is not assignable to type 'never'. The default branch has become a tripwire — the moment the union grows, the compiler tells you exactly where the gap is.
The assertNever Utility
Assigning to a never-typed local variable works, but it's noisy. A better approach is a small utility function that captures the pattern and makes the intent clear:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unhandled variant: ${JSON.stringify(value)}`);
}This function does two things:
- Compile time: the
value: neverparameter means TypeScript will flag a type error if any variant reaches this call — ifshapeis stillEllipse, it's not assignable tonever - Runtime: it throws an error with a descriptive message if a value somehow reaches the default branch (e.g., data from an external source that doesn't match known variants)
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);
}
}Clean, self-documenting, and safe at both compile time and runtime. Define assertNever once in a shared utilities file and use it across your codebase.
Put assertNever in a Shared Utilities File
Define assertNever in a single file — src/utils/assert.ts or similar — and
import it wherever you use discriminated unions. It's a foundational utility
in any TypeScript codebase that takes exhaustiveness seriously. Some teams
also add assertNever calls to their ESLint configuration to enforce
exhaustive switches automatically.
Pattern Matching Helpers
Switch statements are statements — they don't return values directly. This means you can't use them inline in JSX, object literals, or expressions. A common workaround is a match helper that converts a union value into a result by calling the right handler:
function match<T extends { kind: string }, R>(
value: T,
handlers: { [K in T["kind"]]: (variant: Extract<T, { kind: K }>) => R }
): R {
const handler = handlers[value.kind as T["kind"]];
return (handler as (v: T) => R)(value);
}This looks complex, but the usage is clean. TypeScript infers both the allowed keys and the correct variant type for each handler:
const result = match(shape, {
circle: (s) => Math.PI * s.radius ** 2,
rectangle: (s) => s.width * s.height,
triangle: (s) => 0.5 * s.base * s.height,
});If you add ellipse to Shape and forget to add it to the handlers object, TypeScript reports a compile error: Property 'ellipse' is missing in type. You get exhaustiveness checking in expression position without needing a switch statement.
Libraries like ts-pattern provide a battle-tested, feature-rich version of
this pattern — supporting nested matching, guards, wildcards, and more. If you
find yourself writing complex match logic frequently, it's worth evaluating.
But the simple match helper above covers the majority of real-world cases
with zero dependencies.
Handling Nested Variants
When discriminated unions nest — a variant that itself contains another discriminated union — switch your way down each level, keeping narrowing at each step:
type NetworkError =
| { kind: "timeout"; durationMs: number }
| { kind: "unauthorized"; redirectTo: string }
| { kind: "serverError"; statusCode: number };
type ApiResult<T> =
| { status: "success"; data: T }
| { status: "error"; error: NetworkError };
function handleResult<T>(result: ApiResult<T>): string {
switch (result.status) {
case "success":
return `OK: ${JSON.stringify(result.data)}`;
case "error":
// result.error is narrowed to NetworkError here
switch (result.error.kind) {
case "timeout":
return `Timed out after ${result.error.durationMs}ms — retry?`;
case "unauthorized":
return `Auth required — redirect to ${result.error.redirectTo}`;
case "serverError":
return `Server error ${result.error.statusCode}`;
default:
return assertNever(result.error);
}
default:
return assertNever(result);
}
}Each assertNever call guards its own level of the union. Add a new NetworkError variant and the inner assertNever fails to compile. Add a new ApiResult variant and the outer one does.
Real-World Examples
Discriminated union switches are the backbone of reducers in state management. Here's a typed Redux-style reducer:
type CounterAction =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" }
| { type: "set"; value: number };
type CounterState = { count: number; lastAction: string };
function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + action.amount, lastAction: "increment" };
case "decrement":
return { count: state.count - action.amount, lastAction: "decrement" };
case "reset":
return { count: 0, lastAction: "reset" };
case "set":
return { count: action.value, lastAction: "set" };
default:
return assertNever(action);
}
}TypeScript ensures every action type is handled and every property access matches the action variant in that branch. The reducer is self-documenting: the type definition tells you exactly what actions exist, and the switch tells you exactly how each is handled.
Best Practices
Switch on the discriminant directly. Avoid switching on a derived value — switch(getKind(shape)) breaks TypeScript's ability to narrow the original type.
One concern per case. Keep each case block focused. If a case requires complex logic, extract it into a named function:
case "serverError":
return handleServerError(result.error); // function returns stringNever fall through between cases. TypeScript narrows each case independently. Falling through with // intentional fallthrough confuses both readers and TypeScript's narrowing. If two cases share behaviour, extract a shared function and call it from both.
Return from every case instead of breaking. When your switch is inside a function that returns a value, returning from each case is cleaner than assigning to a variable and breaking:
// Prefer — return from each case
function label(shape: Shape): string {
switch (shape.kind) {
case "circle":
return "Circle";
case "rectangle":
return "Rectangle";
case "triangle":
return "Triangle";
default:
return assertNever(shape);
}
}
// Avoid — mutable variable, harder to follow
function label(shape: Shape): string {
let result: string;
switch (shape.kind) {
case "circle":
result = "Circle";
break;
case "rectangle":
result = "Rectangle";
break;
case "triangle":
result = "Triangle";
break;
default:
return assertNever(shape);
}
return result;
}Watch for Implicit Fall-Through
JavaScript switch cases fall through to the next case unless you return,
break, or throw. TypeScript doesn't warn about this by default, though
ESLint's no-fallthrough rule does. Enable it in your ESLint config
alongside TypeScript to catch accidental fall-through early.
Key Takeaways
- TypeScript narrows the full discriminated union type inside each
caseblock — property accesses are checked against the narrowed variant - A
default: assertNever(value)branch provides compile-time and runtime exhaustiveness checking - The
assertNeverutility —(value: never): never => { throw new Error(...) }— is a one-liner worth defining in every TypeScript project - A
matchhelper brings expression-style pattern matching to TypeScript without a dependency - For nested discriminated unions, apply
assertNeverat each level independently
Phase 4 Type Composition Complete
You've now covered the full arc of TypeScript's type composition tools: union types, intersection types, literal types, type guards, discriminated unions, and pattern matching. These six patterns work together — you'll use them in combination constantly. Next up: generics — how to write code that works across many types without sacrificing type safety.