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()
}
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:
// 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:
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:
// 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 — 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 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.
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 — 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