Skip to content

TypeScript Pattern Matching — Discriminated Unions, Exhaustive Checks

DodaTech Updated 2026-06-28 8 min read

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

  1. 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.

  2. How does the never type ensure exhaustive checking? Assigning a union to never errors if all union members aren't handled, because never is the empty union and TypeScript verifies the full union type is consumed.

  3. 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.

  4. 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).

  5. Can discriminated unions work with generics? Yes. Generic discriminated unions enable reusable state machines: type ApiResponse<T> = Success<T> | Error works 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

Is there a built-in match expression in TypeScript?

No. TypeScript uses switch statements with discriminated unions. Third-party libraries (ts-pattern, @effect/match) add pattern matching syntax.

How is pattern matching different from switch statements?

Pattern matching in functional languages can destructure nested data, match on values, and provide exhaustive checking. TypeScript achieves this through discriminated unions and the never check.

Should I use ts-pattern library?

ts-pattern is excellent for complex matching. For simple discriminated unions, vanilla TypeScript is sufficient. Add ts-pattern when matching becomes unwieldy with switch.

Can I use discriminated unions without a string discriminator?

Yes — use boolean (ok: true | false) or symbol discriminators. String literals are most common for readability and debugging.

How do discriminated unions compare to algebraic data types?

TypeScript's discriminated unions are comparable to Rust's enums or Haskell's algebraic data types. They provide the same exhaustiveness guarantees.

What if my cases share some but not all properties?

Use intersection with a base type: type Shape = { position: Point } & (Circle | Rectangle | Triangle).

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