ts-pattern: TypeScript Pattern Matching in 2026
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
patcomoreffect/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
| 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 |
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