Skip to main content
Technology & EngineeringTypescript Patterns124 lines

Discriminated Unions

Model mutually exclusive states with tagged union types and exhaustive narrowing.

Quick Summary21 lines
You are an expert in Discriminated Unions for writing type-safe TypeScript.

## Key Points

- Always use a single, consistent discriminant field name across all members (e.g., `type`, `kind`, `status`).
- Include an `assertNever` default branch to catch unhandled variants at compile time when new members are added.
- Keep each variant interface focused; shared fields belong in a base interface that every variant extends.
- Using a discriminant with type `string` instead of a string literal — TypeScript cannot narrow on a broad `string` type.
- Forgetting to return or break in each case, which silently falls through and bypasses type narrowing.

## Quick Example

```typescript
interface Loading { status: "loading" }
interface Success<T> { status: "success"; data: T }
interface Failure { status: "failure"; error: Error }

type AsyncState<T> = Loading | Success<T> | Failure;
```
skilldb get typescript-patterns-skills/Discriminated UnionsFull skill: 124 lines
Paste into your CLAUDE.md or agent config

Discriminated Unions — TypeScript Patterns

You are an expert in Discriminated Unions for writing type-safe TypeScript.

Overview

A discriminated union is a union of object types that share a common literal-typed field (the discriminant). TypeScript uses this field to narrow the type inside switch and if blocks. Use discriminated unions to model state machines, result types, message protocols, and any domain where an entity can be in exactly one of several distinct states.

Core Philosophy

Discriminated unions encode the principle that an entity's valid operations depend on its current state. Rather than modeling state as a bag of optional fields where half might be undefined at any given moment, you model each state as its own type with exactly the fields that are relevant. The compiler then enforces that you handle each state explicitly before accessing state-specific data.

This pattern aligns TypeScript's type system with how domain logic actually works. An HTTP response that is still loading has no body; a failed request has an error but no data. By making these states distinct types unified under a discriminant, you make illegal states unrepresentable. The compiler becomes your ally: it refuses to let you access .data until you have checked that the status is "success", and it warns you when a new variant is added that you have not handled.

Exhaustiveness checking is the crown jewel of discriminated unions. By adding a default: assertNever(x) branch, you ensure that every switch statement is updated whenever a new variant is introduced. This turns what would be a runtime bug — silently ignoring a new state — into a compile-time error that points you to every location that needs updating.

Core Concepts

Defining a discriminated union:

interface Loading { status: "loading" }
interface Success<T> { status: "success"; data: T }
interface Failure { status: "failure"; error: Error }

type AsyncState<T> = Loading | Success<T> | Failure;

Narrowing with switch:

function render<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case "loading": return "Loading...";
    case "success": return JSON.stringify(state.data);
    case "failure": return state.error.message;
  }
}

Exhaustiveness checking with never:

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

function handle<T>(state: AsyncState<T>): void {
  switch (state.status) {
    case "loading": break;
    case "success": break;
    case "failure": break;
    default: assertNever(state);
  }
}

Implementation Patterns

Event system with discriminated payloads:

type AppEvent =
  | { type: "USER_LOGIN"; userId: string }
  | { type: "USER_LOGOUT" }
  | { type: "ITEM_ADDED"; itemId: string; quantity: number };

function dispatch(event: AppEvent): void {
  switch (event.type) {
    case "USER_LOGIN":
      console.log(`User ${event.userId} logged in`);
      break;
    case "ITEM_ADDED":
      console.log(`Added ${event.quantity} of ${event.itemId}`);
      break;
    case "USER_LOGOUT":
      console.log("Logged out");
      break;
  }
}

Result type for error handling without exceptions:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: "Division by zero" };
  return { ok: true, value: a / b };
}

Best Practices

  • Always use a single, consistent discriminant field name across all members (e.g., type, kind, status).
  • Include an assertNever default branch to catch unhandled variants at compile time when new members are added.
  • Keep each variant interface focused; shared fields belong in a base interface that every variant extends.

Common Pitfalls

  • Using a discriminant with type string instead of a string literal — TypeScript cannot narrow on a broad string type.
  • Forgetting to return or break in each case, which silently falls through and bypasses type narrowing.

Anti-Patterns

Using optional fields instead of discriminated variants. An interface with data?: T and error?: Error allows states where both are present or both are absent. This creates ambiguity that discriminated unions eliminate entirely by making each state's fields explicit and mutually exclusive.

Choosing a non-literal discriminant type. If the discriminant field is typed as string rather than a string literal like "loading" or "success", TypeScript cannot narrow the union in switch or if blocks. Always use literal types for the discriminant.

Skipping the assertNever default branch. Without exhaustiveness checking, adding a new variant to the union compiles silently even though existing switch statements do not handle it. The assertNever pattern is cheap insurance that turns this into a compile error.

Nesting discriminated unions without clear naming. A union whose variants themselves contain unions with the same discriminant field name creates confusion about which level is being narrowed. Use distinct discriminant names (type vs subtype, or kind vs variant) when nesting.

Putting shared logic outside the switch. If you access the discriminant before the switch and store derived values in local variables, those variables do not benefit from narrowing inside each case. Keep discriminant-dependent logic inside the case branches where the compiler has narrowed the type.

Install this skill directly: skilldb add typescript-patterns-skills

Get CLI access →