Skip to main content

ts-pattern: TypeScript Pattern Matching in 2026

·PkgPulse Team

ts-pattern: TypeScript Exhaustive Pattern Matching in 2026

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:

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:

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

npm install ts-pattern
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

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

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

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

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

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:

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

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()
}

ts-pattern vs Alternatives

vs Native switch/case

Featureswitch/casets-pattern
ExhaustivenessManual (never trick)Built-in .exhaustive()
Structural matchingNoYes
Type narrowingLimitedAutomatic
Complex guardsManualP.when()
Return valueStatementExpression
Bundle size0KB~5KB minzipped

vs Effect/Match

Effect.ts includes a Match module, but it's bundled with the entire Effect ecosystem:

// 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:

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.


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 — download trends, health scores, and dependency analysis.

Related: neverthrow vs Effect.ts vs oxide-ts Result Types in TypeScript 2026 · Effect.ts vs fp-ts Functional TypeScript 2026 · Zod v4 vs ArkType vs TypeBox vs Valibot Schema Validation 2026

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.