Skip to main content

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:

  1. It's verbose. The switch (response.status) and repeated case keywords add noise.
  2. 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.
  3. It scatters logic. The condition (response.status) and the extraction (response.data, response.error) are separate from the logic that uses them.
  4. It doesn't compose. You can't use switch as 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 with status: "success" and extracts data into a local variable.
  • => — The arrow separates the pattern from the result expression.
  • Each line is a case. The first matching case wins.

What Changed​

  1. No more switch. match is an expression, not a statement. It returns a value.
  2. No more property access. data is extracted directly in the pattern. You don't write response.data.
  3. Automatic exhaustiveness. If you add a new variant to Response, TypeScript errors on the match expression — you forgot to handle a case.
  4. Cleaner syntax. No case, no break, no default. 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​

Featureswitchmatch
Expression (returns a value)❌✅
Exhaustiveness checkingManual (never trick)Automatic
DestructuringManual property accessBuilt into patterns
GuardsManual if inside caseif 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​

ScenarioUse
Discriminated union with 3+ variantsmatch
Simple boolean checkif
Two variantsif/else or match (either is fine)
Complex nested destructuringmatch
Need exhaustiveness guaranteematch
Runtime type checkingmatch with is patterns

Try This: Pattern Matching​

  1. Rewrite a switch statement from your codebase using match.
  2. Create a discriminated union with 4 variants. Write a match expression that handles all of them. Add a 5th variant and see the exhaustiveness error.
  3. Use nested patterns to match a tree data structure.
  4. Use guard patterns (if) to add conditions to a match case.
  5. Use is patterns 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.