# TypeScript Discriminated Unions and Exhaustive Pattern Matching

> How to use TypeScript discriminated unions and exhaustive matching to eliminate impossible states and make illegal data representations unrepresentable.

- **Published:** 2026-01-20
- **Author:** Clixo
- **Reading time:** 5 min read
- **Tags:** typescript, discriminated-unions, advanced, type-safety, patterns
- **Canonical URL:** https://clixo.sh/blog/typescript-discriminated-unions-exhaustive-matching

A common source of production bugs is state that should not be possible but is. An order that is simultaneously `paid` and `refunded`. A request with a `userId` but no `user` object. A response that has both `data` and `error` set to non-null values at the same time.

TypeScript discriminated unions are the tool for making these states not just unlikely, but literally unrepresentable. Combined with exhaustive matching, they turn missing branches into compile errors.

## What Discriminated Unions Are

A discriminated union is a union type where each member has a common literal field (the discriminant) that TypeScript can use to narrow between them:

```typescript
type Result<T> =
  | { status: "success"; data: T }
  | { status: "error"; code: string; message: string }
  | { status: "loading" };
```

The `status` field is the discriminant. When you check `result.status`, TypeScript narrows the type automatically:

```typescript
function render(result: Result<User>) {
  if (result.status === "loading") {
    return "Loading...";
  }

  if (result.status === "error") {
    return `Error ${result.code}: ${result.message}`;
    // TypeScript knows: result.data does not exist here
  }

  return result.data.email;
  // TypeScript knows: result.data is User here
}
```

No casting. No optional chaining through properties that do not apply. The type narrows based on the discriminant and TypeScript knows exactly what fields exist in each branch.

## Making Impossible States Unrepresentable

The real power of discriminated unions is not just narrowing — it is modeling domain concepts so that invalid combinations cannot be constructed.

Consider a payment record:

```typescript
// Without discriminated unions — any combination is possible
interface Payment {
  status: "pending" | "completed" | "refunded";
  completedAt?: Date;
  refundedAt?: Date;
  transactionId?: string;
}
// Nothing prevents: { status: "pending", completedAt: new Date(), refundedAt: new Date() }
```

With discriminated unions:

```typescript
type Payment =
  | { status: "pending" }
  | { status: "completed"; completedAt: Date; transactionId: string }
  | { status: "refunded"; completedAt: Date; transactionId: string; refundedAt: Date };
```

A pending payment cannot have a `completedAt`. A completed payment must have a `transactionId`. A refunded payment must have both timestamps. These constraints are enforced by the type system — no runtime checks needed for internal code.

```mermaid
stateDiagram-v2
  [*] --> pending
  pending --> completed : payment confirmed
  completed --> refunded : refund requested
  pending --> [*] : cancelled
  completed --> [*] : settled
  refunded --> [*] : refund complete
```

## TypeScript Discriminated Unions with Switch Statements

Switch statements are the natural fit for discriminated unions:

```typescript
function processPayment(payment: Payment): string {
  switch (payment.status) {
    case "pending":
      return "Awaiting payment";
    case "completed":
      return `Completed via ${payment.transactionId}`;
    case "refunded":
      return `Refunded on ${payment.refundedAt.toDateString()}`;
  }
}
```

TypeScript knows the exhaustive set of `status` values and will warn if a case is missing — but only if you help it.

## Exhaustive Matching with the `never` Type

TypeScript's exhaustive checking only kicks in when you tell it to. The `never` type is the mechanism:

```typescript
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function processPayment(payment: Payment): string {
  switch (payment.status) {
    case "pending":
      return "Awaiting payment";
    case "completed":
      return `Completed via ${payment.transactionId}`;
    case "refunded":
      return `Refunded on ${payment.refundedAt.toDateString()}`;
    default:
      return assertNever(payment);
      // If Payment gains a new variant and this switch is not updated,
      // the default branch receives that variant and TypeScript reports a type error.
  }
}
```

When every case is handled, `payment` in the `default` branch has type `never` — because nothing is left. If you add a new variant to `Payment` without updating the switch, `payment` in `default` is no longer `never` and TypeScript reports an error at that call to `assertNever`.

This means adding a new state to a union type is a compile-time check that every switch on that union is updated. Across a codebase with dozens of handlers, this is the difference between a confident refactor and a guessing game.

## Discriminated Unions for API Responses

The pattern transfers directly to API design. Instead of optional fields and status strings, model the actual cases:

```typescript
type ApiResult<T> =
  | { ok: true; data: T; statusCode: 200 | 201 }
  | { ok: false; error: string; statusCode: 400 | 401 | 403 | 404 | 500 };

async function fetchUser(id: string): Promise<ApiResult<User>> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    return {
      ok: false,
      error: await response.text(),
      statusCode: response.status as 400 | 401 | 403 | 404 | 500,
    };
  }
  return { ok: true, data: await response.json(), statusCode: 200 };
}
```

Callers check `result.ok` before accessing `result.data`. TypeScript enforces it. No "data might be undefined even though the request succeeded" ambiguity.

## When to Use Discriminated Unions

Use them when a type has multiple states with different valid fields, and the right fields depend on which state you are in. Common patterns:

- Request/response state (loading, success, error)
- Domain entity states (order, payment, subscription lifecycle stages)
- Feature flags with different configurations per variant
- Event payloads where different events carry different data

Do not use them for simple boolean flags or when the states genuinely share all the same fields. If every variant has the same shape, a plain interface with an optional `status` string is cleaner.

---

Designing state models that prevent invalid combinations is one of the highest-leverage things you can do in a TypeScript codebase. If your team is building a complex domain model and wants it typed correctly, [Clixo](https://clixo.sh/#contact) can help you get the foundations right.

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
