Match. Case. Exhaustive. Three words that change how you write TypeScript forever.
Pattern matching is the most requested feature in TypeScript history. It's been discussed, proposed, and prototyped for years. And in TypeScript 7, it's finally here — not as an experimental flag, not as a staged TC39 proposal, but as a first-class language feature.
This chapter shows you what pattern matching is, why it matters, and how it replaces entire categories of code you used to write by hand.
The Problem: What Pattern Matching Replaces
You've written this code a thousand times:
type Response =
| { status: "success"; data: User[] }
| { status: "error"; error: Error; code: number }
| { status: "loading"; progress: number }
| { status: "idle" };
function handleResponse(response: Response): string {
switch (response.status) {
case "success":
return `Got ${response.data.length} users`;
case "error":
return `Error ${response.code}: ${response.error.message}`;
case "loading":
return `Loading... ${response.progress}%`;
case "idle":
return "Waiting...";
}
}
This works. But it has problems:
- It's verbose. The
switch (response.status)and repeatedcasekeywords add noise. - It's fragile. If you add a new variant to
Response, you might forget to update this switch. The compiler catches it only if you add an exhaustiveness check. - It scatters logic. The condition (
response.status) and the extraction (response.data,response.error) are separate from the logic that uses them. - It doesn't compose. You can't use
switchas an expression. You can't nest patterns. You can't match on multiple values at once.
Pattern matching fixes all of this.
Pattern Matching Syntax
TypeScript 7 introduces the match expression:
function handleResponse(response: Response): string {
return match (response) {
{ status: "success", data } => `Got ${data.length} users`,
{ status: "error", error, code } => `Error ${code}: ${error.message}`,
{ status: "loading", progress } => `Loading... ${progress}%`,
{ status: "idle" } => "Waiting...",
};
}
Let's break this down:
match (response)— The value to match against.{ status: "success", data }— A pattern. It matches objects withstatus: "success"and extractsdatainto a local variable.=>— The arrow separates the pattern from the result expression.- Each line is a case. The first matching case wins.
What Changed
- No more
switch.matchis an expression, not a statement. It returns a value. - No more property access.
datais extracted directly in the pattern. You don't writeresponse.data. - Automatic exhaustiveness. If you add a new variant to
Response, TypeScript errors on thematchexpression — you forgot to handle a case. - Cleaner syntax. No
case, nobreak, nodefault. Just patterns and results.
Pattern Types
TypeScript 7 supports several pattern types:
Object Patterns
match (value) {
{ type: "user", name, age } => `${name} is ${age}`,
{ type: "error", message } => `Error: ${message}`,
}
Matches objects with specific properties and extracts values.
Literal Patterns
match (status) {
200 => "OK",
404 => "Not Found",
500 => "Server Error",
_ => "Unknown",
}
The _ is a wildcard that matches anything. It's the equivalent of default in a switch.
Array Patterns
match (arr) {
[] => "Empty",
[first] => `Single: ${first}`,
[first, second] => `Pair: ${first}, ${second}`,
[first, ...rest] => `First: ${first}, Rest has ${rest.length} items`,
}
Type Patterns
match (value) {
is string => value.toUpperCase(),
is number => value.toFixed(2),
is Date => value.toISOString(),
_ => String(value),
}
The is keyword matches on the runtime type.
Guard Patterns
match (response) {
{ status: "success", data } if data.length > 0 => `Got ${data.length} users`,
{ status: "success", data } => "Got empty result",
{ status: "error", error } if error instanceof ValidationError => `Validation: ${error.message}`,
{ status: "error", error } => `Error: ${error.message}`,
}
The if clause adds a runtime condition. The pattern only matches if both the structure AND the guard are true.
Or Patterns
match (status) {
200 | 201 | 204 => "Success",
400 | 404 => "Client Error",
500 | 502 | 503 => "Server Error",
}
Pattern Matching vs. Switch: A Comparison
| Feature | switch | match |
|---|---|---|
| Expression (returns a value) | ❌ | ✅ |
| Exhaustiveness checking | Manual (never trick) | Automatic |
| Destructuring | Manual property access | Built into patterns |
| Guards | Manual if inside case | if clause in pattern |
| Nested patterns | ❌ | ✅ |
| Multiple values | ❌ | ✅ (or patterns) |
| Type narrowing | ✅ | ✅ (better) |
| Fall-through | ✅ (dangerous) | ❌ (no fall-through) |
Real-World Pattern Matching
Redux-Style Reducers
Before:
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_TODO":
return { ...state, todos: [...state.todos, { id: nextId(), text: action.payload.text, done: false }] };
case "TOGGLE_TODO":
return { ...state, todos: state.todos.map(t => t.id === action.payload.id ? { ...t, done: !t.done } : t) };
case "DELETE_TODO":
return { ...state, todos: state.todos.filter(t => t.id !== action.payload.id) };
case "SET_FILTER":
return { ...state, filter: action.payload.filter };
}
}
After:
function reducer(state: State, action: Action): State {
return match (action) {
{ type: "ADD_TODO", payload: { text } } =>
{ ...state, todos: [...state.todos, { id: nextId(), text, done: false }] },
{ type: "TOGGLE_TODO", payload: { id } } =>
{ ...state, todos: state.todos.map(t => t.id === id ? { ...t, done: !t.done } : t) },
{ type: "DELETE_TODO", payload: { id } } =>
{ ...state, todos: state.todos.filter(t => t.id !== id) },
{ type: "SET_FILTER", payload: { filter } } =>
{ ...state, filter },
};
}
API Route Handlers
type Route =
| { method: "GET"; path: "/users"; query?: { page?: number } }
| { method: "GET"; path: `/users/${number}` }
| { method: "POST"; path: "/users"; body: CreateUserBody }
| { method: "DELETE"; path: `/users/${number}` };
function handleRoute(route: Route): Response {
return match (route) {
{ method: "GET", path: "/users", query } =>
getUsers(query?.page ?? 1),
{ method: "GET", path: `/users/${id}` } =>
getUserById(Number(id)),
{ method: "POST", path: "/users", body } =>
createUser(body),
{ method: "DELETE", path: `/users/${id}` } =>
deleteUser(Number(id)),
};
}
Form Validation
type FieldState<T> =
| { status: "pristine" }
| { status: "dirty"; value: T }
| { status: "valid"; value: T }
| { status: "invalid"; value: T; error: string };
function renderField<T>(field: FieldState<T>): string {
return match (field) {
{ status: "pristine" } => "",
{ status: "dirty", value } => `Value: ${value}`,
{ status: "valid", value } => `✓ ${value}`,
{ status: "invalid", error } => `✗ ${error}`,
};
}
Nested Pattern Matching
Patterns can match nested structures:
type TreeNode<T> =
| { kind: "leaf"; value: T }
| { kind: "branch"; left: TreeNode<T>; right: TreeNode<T> };
function sumTree(node: TreeNode<number>): number {
return match (node) {
{ kind: "leaf", value } => value,
{ kind: "branch", left: { kind: "leaf", value: v1 }, right: { kind: "leaf", value: v2 } } =>
v1 + v2,
{ kind: "branch", left, right } =>
sumTree(left) + sumTree(right),
};
}
Exhaustiveness Checking
The compiler ensures you handle every case:
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape): number {
return match (shape) {
{ kind: "circle", radius } => Math.PI * radius ** 2,
{ kind: "square", sideLength } => sideLength ** 2,
// Missing Triangle — TypeScript errors here!
};
}
// Error: This match expression is not exhaustive.
// Missing case: { kind: "triangle", ... }
No more assertNever in the default case. No more runtime errors from unhandled variants. The compiler guarantees exhaustiveness.
When to Use Pattern Matching vs. If/Switch
| Scenario | Use |
|---|---|
| Discriminated union with 3+ variants | match |
| Simple boolean check | if |
| Two variants | if/else or match (either is fine) |
| Complex nested destructuring | match |
| Need exhaustiveness guarantee | match |
| Runtime type checking | match with is patterns |
Try This: Pattern Matching
- Rewrite a
switchstatement from your codebase usingmatch. - Create a discriminated union with 4 variants. Write a
matchexpression that handles all of them. Add a 5th variant and see the exhaustiveness error. - Use nested patterns to match a tree data structure.
- Use guard patterns (
if) to add conditions to a match case. - Use
ispatterns to match on runtime types.
Time needed: 20 minutes.
What to notice: How match eliminates entire categories of boilerplate. How exhaustiveness checking catches missing cases at compile time. How patterns make the structure of your data visible in the code that processes it.
The Bridge
Pattern matching changes how you handle variants. But there's another TypeScript 7 feature that changes how you write classes: decorators.
Decorators have been in TypeScript since the early days, but they were experimental — hidden behind a flag, with a spec that kept changing. TypeScript 7 finally stabilizes them. And the new decorators standard is cleaner, more powerful, and fully supported.
In the next chapter: TypeScript 7 decorators — the new standard, how they differ from the old experimental decorators, and how to use them for logging, validation, dependency injection, and more.