<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/ts-pattern-typescript-exhaustive-pattern-matching-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/ts-pattern-typescript-exhaustive-pattern-matching-2026/raw.md -->
<!-- Source path: content/guides/ts-pattern-typescript-exhaustive-pattern-matching-2026.mdx -->

---
og_image: "/images/guides/ts-pattern-typescript-exhaustive-pattern-matching-2026.webp"
title: "ts-pattern: TypeScript Pattern Matching in 2026"
description: "ts-pattern brings exhaustive pattern matching to TypeScript in 2026: match(), P guards, when(), exhaustive checking, and real-world comparison with switch."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["typescript", "pattern-matching", "functional", "npm", "javascript"]
tier: 1
---

## TL;DR

TypeScript's `switch`/`if-else` chains don't give you exhaustiveness checking — the compiler won't tell you when you've forgotten to handle a case. **ts-pattern** brings proper pattern matching to TypeScript: exhaustive `match()` expressions that the compiler verifies at the type level, structural matching on complex objects, and guard functions that narrow types automatically. At ~900K weekly downloads and with a TC39 pattern matching proposal still years away from shipping, ts-pattern is the practical choice for robust discriminated union handling in 2026.

## Key Takeaways

- **Exhaustiveness at compile time**: `match(value).with(...).exhaustive()` — TypeScript errors if you miss a case
- **Structural pattern matching**: match on nested object shapes, not just primitive values
- **Type narrowing via patterns**: types are narrowed inside `.with()` branches automatically
- **P (pattern) guards**: `P.string`, `P.number`, `P.when()`, `P.union()`, `P.array()` for powerful matching
- **~900K weekly downloads**: significantly more popular than alternatives like `patcom` or `effect/match`
- **TC39 proposal exists** but is Stage 1 — ts-pattern is the practical solution for 2026 and beyond

---

## The Problem: TypeScript's Switch Statement Gaps

The classic approach to discriminated unions in TypeScript:

```typescript
type PaymentStatus =
  | { type: 'pending'; amount: number }
  | { type: 'paid'; amount: number; paidAt: Date; transactionId: string }
  | { type: 'failed'; amount: number; reason: string; retryCount: number }
  | { type: 'refunded'; amount: number; refundedAt: Date }

function getStatusMessage(status: PaymentStatus): string {
  switch (status.type) {
    case 'pending':
      return `Payment of $${status.amount} is pending`
    case 'paid':
      return `Paid $${status.amount} on ${status.paidAt.toLocaleDateString()}`
    case 'failed':
      return `Payment failed: ${status.reason}`
    // ❌ Forgot 'refunded' — TypeScript does NOT error here by default
    default:
      return 'Unknown status'
  }
}
```

The `default` case swallows the forgotten `'refunded'` case silently. You can use TypeScript's `never` trick to get exhaustiveness:

```typescript
function assertNever(x: never): never {
  throw new Error('Unexpected value: ' + x)
}

switch (status.type) {
  case 'pending': return '...'
  case 'paid': return '...'
  case 'failed': return '...'
  // Now TS errors: Argument of type '{ type: "refunded"; ... }' is not assignable to 'never'
  default: return assertNever(status)
}
```

This works but is verbose, requires a utility function, and doesn't give you structural matching (matching on the *shape* of an object, not just a discriminant).

---

## ts-pattern Basics

```bash
npm install ts-pattern
```

```typescript
import { match, P } from 'ts-pattern'

type PaymentStatus =
  | { type: 'pending'; amount: number }
  | { type: 'paid'; amount: number; paidAt: Date; transactionId: string }
  | { type: 'failed'; amount: number; reason: string; retryCount: number }
  | { type: 'refunded'; amount: number; refundedAt: Date }

function getStatusMessage(status: PaymentStatus): string {
  return match(status)
    .with({ type: 'pending' }, (s) => `Payment of $${s.amount} is pending`)
    .with({ type: 'paid' }, (s) => `Paid $${s.amount} on ${s.paidAt.toLocaleDateString()}`)
    .with({ type: 'failed' }, (s) => `Payment failed: ${s.reason}`)
    // ✅ TypeScript errors here: "Pattern is not exhaustive"
    // .with({ type: 'refunded' }, ...)
    .exhaustive()  // ← This is the key — compile-time exhaustiveness check
}
```

Remove `.exhaustive()` and add `.otherwise(() => 'Unknown')` for non-exhaustive matching.

---

## Pattern Guards: The P Namespace

The `P` namespace provides pattern builders for complex matching:

### Primitive Type Guards

```typescript
import { match, P } from 'ts-pattern'

function formatValue(value: string | number | boolean | null | undefined): string {
  return match(value)
    .with(P.string, (v) => `String: "${v}"`)        // v is typed as string
    .with(P.number, (v) => `Number: ${v}`)           // v is typed as number
    .with(P.boolean, (v) => `Boolean: ${v}`)         // v is typed as boolean
    .with(P.nullish, () => 'Null or undefined')      // matches null and undefined
    .exhaustive()
}
```

### `P.when()` for Custom Guards

```typescript
type ApiResponse<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; code: number; message: string }

function handleResponse(response: ApiResponse<User>): string {
  return match(response)
    .with({ status: 'error', code: P.when(code => code >= 500) }, (r) => {
      // r.code is narrowed to number, but we know it's >= 500
      return `Server error ${r.code}: ${r.message}`
    })
    .with({ status: 'error', code: P.when(code => code === 401) }, () => {
      return 'Unauthorized — please log in'
    })
    .with({ status: 'error' }, (r) => `Client error ${r.code}: ${r.message}`)
    .with({ status: 'success' }, (r) => `User: ${r.data.name}`)
    .exhaustive()
}
```

### `P.union()` for Multiple Matches

```typescript
type UserRole = 'admin' | 'editor' | 'viewer' | 'guest'

function canEdit(role: UserRole): boolean {
  return match(role)
    .with(P.union('admin', 'editor'), () => true)   // Matches admin OR editor
    .with(P.union('viewer', 'guest'), () => false)  // Matches viewer OR guest
    .exhaustive()
}
```

### `P.array()` for Array Patterns

```typescript
function describeList(items: string[]): string {
  return match(items)
    .with([], () => 'Empty list')
    .with([P.string], ([item]) => `Single item: ${item}`)
    .with([P.string, P.string], ([a, b]) => `Two items: ${a} and ${b}`)
    .with(P.array(P.string), (items) => `${items.length} items`)
    .exhaustive()
}
```

### Nested Structural Matching

```typescript
type Notification =
  | { type: 'message'; payload: { from: string; text: string; priority: 'low' | 'high' } }
  | { type: 'payment'; payload: { amount: number; currency: string } }
  | { type: 'system'; payload: { level: 'info' | 'warning' | 'error'; code: number } }

function processNotification(notification: Notification): void {
  match(notification)
    // Match on nested properties
    .with({ type: 'message', payload: { priority: 'high' } }, (n) => {
      sendPushNotification(n.payload.from, n.payload.text)
    })
    .with({ type: 'message', payload: { priority: 'low' } }, (n) => {
      queueForBatch(n.payload)
    })
    // Match with P.when on nested numeric value
    .with({ type: 'payment', payload: { amount: P.when(a => a > 1000) } }, (n) => {
      flagForReview(n.payload)
    })
    .with({ type: 'payment' }, (n) => {
      processPayment(n.payload)
    })
    .with({ type: 'system', payload: { level: P.union('warning', 'error') } }, (n) => {
      alertOpsTeam(n.payload)
    })
    .with({ type: 'system' }, () => { /* info log */ })
    .exhaustive()
}
```

---

## Real-World Use Cases

### State Machine Transitions

ts-pattern shines for state machine logic where every state transition must be handled:

```typescript
type OrderState =
  | { status: 'draft'; items: CartItem[] }
  | { status: 'placed'; orderId: string; items: CartItem[]; placedAt: Date }
  | { status: 'processing'; orderId: string; estimatedDelivery: Date }
  | { status: 'shipped'; orderId: string; trackingNumber: string }
  | { status: 'delivered'; orderId: string; deliveredAt: Date }
  | { status: 'cancelled'; orderId: string; reason: string }

type OrderAction =
  | { type: 'PLACE_ORDER' }
  | { type: 'START_PROCESSING'; estimatedDelivery: Date }
  | { type: 'SHIP'; trackingNumber: string }
  | { type: 'DELIVER' }
  | { type: 'CANCEL'; reason: string }

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  return match({ state, action })
    .with({ state: { status: 'draft' }, action: { type: 'PLACE_ORDER' } }, ({ state }) => ({
      status: 'placed' as const,
      orderId: generateId(),
      items: state.items,
      placedAt: new Date(),
    }))
    .with(
      { state: { status: 'placed' }, action: { type: 'START_PROCESSING' } },
      ({ state, action }) => ({
        status: 'processing' as const,
        orderId: state.orderId,
        estimatedDelivery: action.estimatedDelivery,
      })
    )
    .with(
      { state: { status: 'processing' }, action: { type: 'SHIP' } },
      ({ state, action }) => ({
        status: 'shipped' as const,
        orderId: state.orderId,
        trackingNumber: action.trackingNumber,
      })
    )
    .with(
      { state: { status: 'shipped' }, action: { type: 'DELIVER' } },
      ({ state }) => ({
        status: 'delivered' as const,
        orderId: state.orderId,
        deliveredAt: new Date(),
      })
    )
    // Cancel from any non-final state
    .with(
      {
        state: { status: P.union('draft', 'placed', 'processing') },
        action: { type: 'CANCEL' },
      },
      ({ state, action }) => ({
        status: 'cancelled' as const,
        orderId: 'orderId' in state ? state.orderId : generateId(),
        reason: action.reason,
      })
    )
    // Any other combination is invalid — return state unchanged
    .otherwise(({ state }) => state)
}
```

### API Error Handling

```typescript
type HttpError =
  | { type: 'network'; message: string }
  | { type: 'timeout'; retryAfter: number }
  | { type: 'http'; status: number; body: unknown }
  | { type: 'parse'; raw: string }

function handleApiError(error: HttpError): UserFacingError {
  return match(error)
    .with({ type: 'network' }, (e) => ({
      title: 'Connection problem',
      message: e.message,
      retryable: true,
    }))
    .with({ type: 'timeout' }, (e) => ({
      title: 'Request timed out',
      message: `Retry in ${e.retryAfter} seconds`,
      retryable: true,
      retryAfter: e.retryAfter,
    }))
    .with({ type: 'http', status: 401 }, () => ({
      title: 'Session expired',
      message: 'Please log in again',
      retryable: false,
    }))
    .with({ type: 'http', status: 403 }, () => ({
      title: 'Access denied',
      message: "You don't have permission for this action",
      retryable: false,
    }))
    .with({ type: 'http', status: P.when(s => s >= 500) }, (e) => ({
      title: 'Server error',
      message: `Error ${e.status} — our team has been notified`,
      retryable: true,
    }))
    .with({ type: 'http' }, (e) => ({
      title: `Error ${e.status}`,
      message: 'Unexpected response',
      retryable: false,
    }))
    .with({ type: 'parse' }, () => ({
      title: 'Unexpected response format',
      message: 'Please contact support',
      retryable: false,
    }))
    .exhaustive()
}
```

---

## Why TypeScript Needs Pattern Matching

TypeScript's type system is one of the most sophisticated in mainstream programming languages — structural typing, conditional types, template literal types, infer within generics. But for one of the most common tasks in typed code — exhaustively handling a union type — the language offers surprisingly little.

The `switch` statement is JavaScript's native branch-on-value construct. TypeScript can narrow types inside `switch` branches, and with the `never` trick in the default case, you can get compile-time exhaustiveness. But this setup has friction: you need a `assertNever` utility function (or know to write one), the `default` case must be there, and there's no structural matching — you can only switch on a single primitive value, not on the shape of an object.

The `if`/`else if` chain is flexible but provides even less structure. You can absolutely handle all cases in an `if`/`else if` chain, but TypeScript provides no guarantee that you have — the compiler won't warn you when you add a new case to a union and forget to update all the places it's handled.

This gap exists because JavaScript pattern matching — the feature that languages like Rust, Haskell, Scala, and Python 3.10 use for exactly this problem — is still in the TC39 proposal pipeline at Stage 1. Stage 1 means the committee has agreed it's worth exploring. It doesn't mean it's shipping soon. Realistic timelines based on how long Stage 2 and Stage 3 take in TypeScript features suggest pattern matching in JavaScript natively is a 2027-2028 proposition at the earliest.

ts-pattern fills this gap today. It's a library implementation of pattern matching semantics on top of JavaScript values and TypeScript's type system. The API is ergonomic enough that teams use it as a primary tool in their codebase rather than a workaround, and the ~5KB bundle cost is small enough that it doesn't meaningfully affect any production application.

## ts-pattern in React Applications

Pattern matching is particularly useful in React for rendering based on application state, because React components frequently receive union-typed props or render based on async state that has multiple distinct shapes.

The most common pattern is async data state rendering. React Query, SWR, and similar libraries expose query state as a discriminated union of loading, error, and success states. Without pattern matching, handling all three states correctly often results in nested ternary expressions or early returns that are easy to get wrong:

```tsx
// Without ts-pattern — ternary nesting gets ugly
function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // Easy to miss cases or make errors in nested ternaries
  return isLoading ? (
    <LoadingSpinner />
  ) : isError ? (
    <ErrorMessage error={error} />
  ) : data ? (
    <ProfileCard user={data} />
  ) : null;
}

// With ts-pattern — explicit about every case
function UserProfile({ userId }: { userId: string }) {
  const query = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return match(query)
    .with({ status: 'pending' }, () => <LoadingSpinner />)
    .with({ status: 'error', error: P.instanceOf(Error) }, ({ error }) => (
      <ErrorMessage message={error.message} />
    ))
    .with({ status: 'success', data: P.nonNullable }, ({ data }) => (
      <ProfileCard user={data} />
    ))
    .otherwise(() => null);
}
```

The ts-pattern version is more explicit about each case it handles and why. The `P.instanceOf(Error)` guard ensures TypeScript knows `error` is an `Error` instance inside that branch. The `P.nonNullable` guard handles the case where `data` might be `undefined` even in the success state — a real edge case in some query configurations that the ternary version silently missed.

Form state rendering is another high-value use case. Multi-step forms often have a complex state machine where the UI should render differently based on which step is active and what has been completed:

```tsx
type FormState =
  | { step: 'email'; email?: string }
  | { step: 'verification'; email: string; code?: string }
  | { step: 'profile'; email: string; name?: string; bio?: string }
  | { step: 'complete'; userId: string }

function OnboardingForm({ state }: { state: FormState }) {
  return match(state)
    .with({ step: 'email' }, (s) => <EmailStep defaultEmail={s.email} />)
    .with({ step: 'verification' }, (s) => (
      <VerificationStep email={s.email} defaultCode={s.code} />
    ))
    .with({ step: 'profile' }, (s) => (
      <ProfileStep email={s.email} defaultName={s.name} defaultBio={s.bio} />
    ))
    .with({ step: 'complete' }, (s) => <SuccessScreen userId={s.userId} />)
    .exhaustive()
}
```

The `.exhaustive()` call on the form component is valuable: when you add a new step to the `FormState` union, TypeScript immediately errors on every component that handles that state, telling you exactly which rendering code needs to be updated.

## Migration: Adding ts-pattern Incrementally

ts-pattern doesn't require a rewrite of existing code. The most effective adoption pattern is to start using it for new code and migrate existing `switch` statements when you touch them for other reasons.

The migration from `switch` to ts-pattern is mechanical. The structure maps directly:

```typescript
// Before: switch with assertNever
function processEvent(event: DomainEvent): void {
  switch (event.type) {
    case 'user_created':
      onUserCreated(event.payload);
      break;
    case 'order_placed':
      onOrderPlaced(event.payload);
      break;
    case 'payment_processed':
      onPaymentProcessed(event.payload);
      break;
    default:
      assertNever(event);
  }
}

// After: ts-pattern
function processEvent(event: DomainEvent): void {
  match(event)
    .with({ type: 'user_created' }, (e) => onUserCreated(e.payload))
    .with({ type: 'order_placed' }, (e) => onOrderPlaced(e.payload))
    .with({ type: 'payment_processed' }, (e) => onPaymentProcessed(e.payload))
    .exhaustive();
}
```

The win isn't just syntax — it's that the `assertNever` utility is no longer needed, the `break` statements are gone, and the type narrowing inside each branch is handled automatically. When `DomainEvent` gets a new variant, TypeScript reports an exhaustiveness error on every `match(...).exhaustive()` call that doesn't handle the new type, across your entire codebase.

If you're working in a large codebase and want to identify which `switch` statements are good candidates for migration, look for: switches on discriminated union `type` fields, switches with more than 4 cases, and switches where some branches need to access discriminant-specific properties. These are where ts-pattern provides the most leverage over native `switch`.

## When to Reach for ts-pattern

ts-pattern adds value in proportion to the complexity of your union types. For simple string or number switches, native `switch` with the `assertNever` pattern is fine — the overhead isn't worth it. For complex state machines with nested union types, ts-pattern is clearly better.

The clearest use cases: event sourcing systems where domain events are discriminated unions and every handler must cover all event types; GraphQL resolvers where the `__typename` discriminant is used to route different data shapes; form state machines in multi-step flows; and API response handling where different status codes require meaningfully different logic.

The cases where ts-pattern adds less value: simple feature flags (just use a `boolean` or ternary), simple ternary expressions with one or two cases (the overhead of `match().with().with().exhaustive()` outweighs the benefit), and performance-critical hot paths where the function is called millions of times per second (the library overhead, while small, is non-zero).

A useful signal for when to add ts-pattern to a new codebase: count how many discriminated union types you have. If you have more than 5-10 union types being handled in multiple places, ts-pattern's compile-time exhaustiveness checking becomes a genuine productivity multiplier. Every time you add a new variant to a union, the TypeScript compiler reports every location that needs updating — exhaustive match expressions become a free refactoring tool that catches places you'd otherwise miss.

Teams that have adopted ts-pattern in larger codebases consistently report fewer runtime errors from unhandled cases and faster development cycles when extending domain models. The upfront cost is a new API to learn; the ongoing benefit is TypeScript-verified correctness across every branch of your business logic. For TypeScript developers working with complex domain models, event-driven architectures, or multi-state UI, the investment pays back quickly. See also [neverthrow for Result types](/guides/neverthrow-vs-effect-ts-vs-oxide-ts-result-types-2026) — ts-pattern pairs well with result-typed error handling for a fully type-safe control flow story.

## ts-pattern vs Alternatives

### vs Native switch/case

| Feature | switch/case | ts-pattern |
|---------|------------|------------|
| Exhaustiveness | Manual (never trick) | Built-in `.exhaustive()` |
| Structural matching | No | Yes |
| Type narrowing | Limited | Automatic |
| Complex guards | Manual | `P.when()` |
| Return value | Statement | Expression |
| Bundle size | 0KB | ~5KB minzipped |

The most important difference in day-to-day use is the return value row. A `switch` statement is a statement — it cannot be used as an expression, which forces you to either declare a `let` variable before the switch and mutate it inside, or extract the switch into a function. ts-pattern's `match()` is an expression that returns a value directly, which enables much cleaner code in JSX and functional pipelines. The difference between `let message; switch (status) { ... }` and `const message = match(status).with(...).exhaustive()` affects readability more than you'd expect when repeated throughout a codebase.

The `switch` statement also falls apart quickly when you need to match on multiple conditions simultaneously. TypeScript's switch discriminates on a single value. To check both the `type` of an event and a property of its payload, you'd need nested switches or complex boolean conditions in a `case`. ts-pattern's structural matching handles this in a single `.with()` call: `.with({ type: 'payment', payload: { amount: P.when(a => a > 1000) } }, ...)`.

### vs Effect/Match

[Effect.ts](https://effect.website) includes a `Match` module, but it's bundled with the entire Effect ecosystem:

```typescript
// Effect/Match (from Effect.ts)
import { Match } from 'effect'

const result = pipe(
  value,
  Match.type<PaymentStatus>(),
  Match.when({ type: 'pending' }, (s) => `Pending: $${s.amount}`),
  Match.when({ type: 'paid' }, (s) => `Paid: $${s.amount}`),
  Match.exhaustive
)
```

Effect's Match is more powerful (integrates with Effect's pipe, error handling, etc.) but adds significant bundle weight. ts-pattern is the right choice if you're not already using Effect.

### vs switch-exhaustive

`switch-exhaustive` is a lightweight alternative focused purely on exhaustiveness:

```typescript
import switchExhaustive from 'switch-exhaustive'

const message = switchExhaustive(status.type, {
  pending: () => 'Pending',
  paid: () => 'Paid',
  failed: () => 'Failed',
  refunded: () => 'Refunded',
})
```

Simpler but lacks structural matching — good for simple discriminated unions, ts-pattern for complex cases.

## Package Health

| Package | Weekly Downloads | Bundle Size | Stars |
|---------|-----------------|-------------|-------|
| `ts-pattern` | ~900K | ~5KB | 12K+ |
| `switch-exhaustive` | ~50K | ~1KB | 800+ |
| `@effect/schema` (includes Match) | growing | significant | 7K+ |

ts-pattern has maintained steady growth without the volatility of packages that depend on a single framework's popularity. It's framework-agnostic — works equally well in Next.js, Express, Fastify, React Native, and any other TypeScript environment, server or client.

The version 5.x release in 2023 brought significant API improvements: cleaner `P` namespace ergonomics, better error messages when patterns don't match expected types, and improved performance for the most common matching patterns. If you're using ts-pattern v4 or earlier, the v5 migration is worth doing — the `match().with().exhaustive()` chain is the same, but some of the more advanced pattern builders changed APIs.

## TypeScript Version Requirements and Bundle Size

- **TypeScript**: 4.2+ required; 5.0+ recommended for best inference
- **Bundle size**: ~5KB minzipped — minimal impact
- **Tree-shaking**: Yes — only `P.*` guards you use are included
- **Zero dependencies**
- **ESM + CJS**: Dual package, works everywhere

---

## Methodology

- Download data from npmjs.com API, March 2026 weekly averages
- ts-pattern version: v5.x
- Sources: ts-pattern GitHub (github.com/gvergnaud/ts-pattern), TypeScript official documentation
- TC39 Pattern Matching proposal: github.com/tc39/proposal-pattern-matching (Stage 1)

---

*Track ts-pattern vs alternatives on [PkgPulse](/compare/ts-pattern-vs-effect) — download trends, health scores, and dependency analysis.*

*Related: [neverthrow vs Effect.ts vs oxide-ts Result Types in TypeScript 2026](/guides/neverthrow-vs-effect-ts-vs-oxide-ts-result-types-2026) · [Effect.ts vs fp-ts Functional TypeScript 2026](/guides/effect-ts-vs-fp-ts-functional-typescript-2026) · [Zod v4 vs ArkType vs TypeBox vs Valibot Schema Validation 2026](/guides/zod-v4-vs-arktype-vs-typebox-vs-valibot-schema-2026)*
