TypeScript Pattern Matching — Discriminated Unions, Exhaustive Checks
In this tutorial, you will learn about TypeScript Pattern Matching. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript's discriminated unions and exhaustive Type Checking provide compile-time pattern matching that ensures every case is handled — if you add a new variant, the compiler forces you to update every switch statement that processes it.
What You'll Learn
- Discriminated unions for state modeling
- Exhaustive switch with
never - Pattern matching with type guards
- Expression-based matching functions
- Functional match patterns
- Real-world state machine modeling
Why It Matters
Without exhaustive pattern matching, adding a new enum variant or union member creates runtime bugs in every function that processes the union but wasn't updated. TypeScript's compiler forces you to handle every case — your code literally won't compile if you miss one.
Real-World Use
The Durga Antivirus Pro scan engine models file scan results as a discriminated union: Clean | Infected | Error | Pending | Scanning. Every component that renders scan results must handle all five states — TypeScript enforces this at compile time.
Learning Path
flowchart LR A[Async Patterns] --> B[Pattern Matching] B --> C[Performance] B --> D[You Are Here] C --> E[Project: REST API] D --> F[Project: Dashboard]
Discriminated Unions
A discriminated union uses a common property (the discriminator) to distinguish between variants:
interface Circle {
kind: 'circle';
radius: number;
}
interface Rectangle {
kind: 'rectangle';
width: number;
height: number;
}
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
type Shape = Circle | Rectangle | Triangle;
Each variant shares the kind property but has different data. TypeScript uses the kind value to narrow the type.
Exhaustive Switch Statement
The standard way to handle discriminated unions — the compiler warns if a case is missing:
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// shape is narrowed to Circle
return Math.PI * shape.radius ** 2;
case 'rectangle':
// shape is narrowed to Rectangle
return shape.width * shape.height;
case 'triangle':
// shape is narrowed to Triangle
return (shape.base * shape.height) / 2;
}
}
console.log(area({ kind: 'circle', radius: 5 })); // ~78.54
console.log(area({ kind: 'rectangle', width: 4, height: 6 })); // 24
console.log(area({ kind: 'triangle', base: 3, height: 8 })); // 12
Expected output:
78.53981633974483
24
12
The never Type for Exhaustiveness
Add a default case that uses never to catch missing variants at compile time:
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function areaWithExhaustiveCheck(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
return assertNever(shape);
}
}
If you add a new Square variant to Shape, TypeScript will error on assertNever(shape) because Square isn't assignable to never. This forces you to add the new case.
Expression-Based Match Function
Switch statements are statements, not expressions. For functional style, build a match helper:
type Pattern<T, R> = {
[K in T extends { kind: infer K } ? K : never]: (value: Extract<T, { kind: K }>) => R;
};
function match<T extends { kind: string }, R>(
value: T,
patterns: Pattern<T, R>
): R {
const handler = patterns[value.kind as keyof Pattern<T, R>];
if (!handler) {
throw new Error(`No handler for kind: ${value.kind}`);
}
return handler(value as any);
}
// Usage
type Shape = Circle | Rectangle | Triangle;
function describe(shape: Shape): string {
return match(shape, {
circle: (c) => `Circle with radius ${c.radius}`,
rectangle: (r) => `Rectangle ${r.width}x${r.height}`,
triangle: (t) => `Triangle base ${t.base}, height ${t.height}`,
});
}
console.log(describe({ kind: 'circle', radius: 10 }));
// "Circle with radius 10"
State Machines with Discriminated Unions
Model complex state transitions with typed unions:
// Payment state machine
interface IdleState {
status: 'idle';
}
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
transactionId: string;
amount: number;
}
interface ErrorState {
status: 'error';
message: string;
retryCount: number;
}
type PaymentState = IdleState | LoadingState | SuccessState | ErrorState;
// React reducer example
type PaymentAction =
| { type: 'START_PAYMENT' }
| { type: 'PAYMENT_SUCCESS'; transactionId: string; amount: number }
| { type: 'PAYMENT_ERROR'; message: string }
| { type: 'RETRY' }
| { type: 'RESET' };
function paymentReducer(state: PaymentState, action: PaymentAction): PaymentState {
switch (action.type) {
case 'START_PAYMENT':
return { status: 'loading' };
case 'PAYMENT_SUCCESS':
return {
status: 'success',
transactionId: action.transactionId,
amount: action.amount,
};
case 'PAYMENT_ERROR':
return {
status: 'error',
message: action.message,
retryCount: state.status === 'error' ? state.retryCount + 1 : 1,
};
case 'RETRY':
return { status: 'loading' };
case 'RESET':
return { status: 'idle' };
}
}
The discriminated union ensures you can't dispatch invalid actions for the current state — though runtime validation is still needed.
Type Guards for Complex Patterns
When discriminator isn't a simple string, use custom type guards:
interface ApiSuccess<T> {
ok: true;
data: T;
timestamp: Date;
}
interface ApiError {
ok: false;
error: {
code: string;
message: string;
};
}
type ApiResponse<T> = ApiSuccess<T> | ApiError;
function isSuccess<T>(response: ApiResponse<T>): response is ApiSuccess<T> {
return response.ok === true;
}
function handleResponse<T>(response: ApiResponse<T>): T {
if (isSuccess(response)) {
// narrowed to ApiSuccess<T>
console.log(`Success at ${response.timestamp}`);
return response.data;
}
// narrowed to ApiError
throw new Error(`API Error ${response.error.code}: ${response.error.message}`);
}
Practical State Modeling
Model complex workflows with nested discriminated unions:
interface FilePending {
state: 'pending';
fileName: string;
}
interface FileUploading {
state: 'uploading';
fileName: string;
progress: number; // 0-100
}
interface FileProcessing {
state: 'processing';
fileName: string;
startedAt: Date;
}
interface FileComplete {
state: 'complete';
fileName: string;
result: {
checksum: string;
size: number;
duration: number;
};
}
interface FileFailed {
state: 'failed';
fileName: string;
error: {
code: string;
message: string;
retryable: boolean;
};
}
type FileState = FilePending | FileUploading | FileProcessing | FileComplete | FileFailed;
function renderFileState(file: FileState): string {
switch (file.state) {
case 'pending':
return `⏳ ${file.fileName}: Waiting to upload`;
case 'uploading':
return `⬆️ ${file.fileName}: Uploading (${file.progress}%)`;
case 'processing':
return `🔄 ${file.fileName}: Processing...`;
case 'complete':
return `✅ ${file.fileName}: Done (${file.result.duration}s)`;
case 'failed':
return `❌ ${file.fileName}: ${file.error.message}`;
}
}
Common Mistakes
1. Not using the never check
Without assertNever in the default case, you lose compile-time exhaustiveness checking. A new variant silently falls through.
2. Using string enums instead of discriminated unions
String enums don't carry type-specific data. Use discriminated unions when each variant has different properties.
3. Forgetting to return in each switch case
Each case should return or break. Missing returns lead to fall-through bugs. Enable noFallthroughCasesInSwitch in tsconfig.
4. Putting shared properties on only some variants
If all variants share id or createdAt, include them in a base type and intersect:
interface BaseEvent {
id: string;
timestamp: Date;
}
type Event = BaseEvent & (ClickEvent | ScrollEvent | SubmitEvent);
5. Over-nesting discriminated unions
Two levels of nesting is usually fine. Three or more suggests you need separate union types or a state machine class.
6. Using any or type assertions to bypass exhaustiveness
If you find yourself writing as Shape, you're working against the type system. Add the missing variant instead.
7. Not using Extract for filtering union members
Extract<T, { kind: 'circle' }> extracts circle from the union. Useful in generic utility functions.
Practice Questions
What is a discriminated union in TypeScript? A union type where each member shares a common property (discriminator) with literal types, enabling TypeScript to narrow the type when the discriminator is checked.
How does the
nevertype ensure exhaustive checking? Assigning a union tonevererrors if all union members aren't handled, becauseneveris the empty union and TypeScript verifies the full union type is consumed.What's the difference between a switch statement match and a match function? Switch is a statement (doesn't produce a value). A match function is an expression (returns a value), enabling functional composition.
How do you add a new variant to a discriminated union safely? Add the new interface, include it in the union type, and TypeScript will error everywhere that doesn't handle it (if you use exhaustive checking).
Can discriminated unions work with generics? Yes. Generic discriminated unions enable reusable state machines:
type ApiResponse<T> = Success<T> | Errorworks across different data types.
Challenge
Model a shopping cart state machine with discriminated unions: Empty, WithItems, Checkout, Paid, Failed. Each state has typed transitions — for example, you can only go to Checkout from WithItems, and only to Paid from Checkout.
FAQ
Mini Project
Build a file management system state machine:
- Discriminated union: Model file states (uploading, uploaded, processing, compressed, error, deleted)
- Exhaustive render: A function that renders UI for each state
- State transitions: Type-safe transition functions that only allow valid state changes
- Match utility: Create a reusable match function for the file state
- Nested data: Each state carries different data (progress, result, error details)
What's Next
You've mastered pattern matching with TypeScript. Now optimize your application performance with {{< ref "54-performance" >}}, or build a complete REST API with {{< ref "55-project-rest-api" >}}.
For a practical dashboard project, see {{< ref "56-project-react-dashboard" >}}.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro