Skip to content
Codeloom
TypeScript

Discriminated Unions for State Machines in TypeScript

Model application states, async flows, and finite state machines with discriminated unions and exhaustive pattern matching.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Structure discriminated unions with a shared tag property
  • Use exhaustive switch statements for safe state handling
  • Model async data, forms, and finite state machines

Prerequisites

  • Basic TypeScript union types
  • Familiarity with type narrowing

A discriminated union is a union of object types that share a common literal property, called the discriminant or tag. TypeScript uses this tag to narrow the type inside switch and if statements, giving you access to variant-specific properties without unsafe casts. This pattern is the foundation for modeling states, events, and workflows in a type-safe way.

The Basic Pattern

Every variant in the union has a shared property with a unique literal type.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rect'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
  }
}

Inside each case, TypeScript narrows shape to the specific variant. You can access radius only in the 'circle' branch, and width only in the 'rect' branch.

Exhaustive Checking

The real power comes from ensuring every variant is handled. If you add a new shape variant later, unhandled branches produce compile errors.

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

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    default:
      return assertNever(shape);
  }
}

If someone adds { kind: 'polygon'; sides: number[]; } to the Shape union, the default branch no longer compiles because shape is not never. This forces the developer to handle the new case.

Modeling Async State

One of the most common uses is modeling the states of an async operation.

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function renderUsers(state: AsyncState<string[]>): string {
  switch (state.status) {
    case 'idle':
      return 'Click to load users';
    case 'loading':
      return 'Loading...';
    case 'success':
      return state.data.join(', ');
    case 'error':
      return `Error: ${state.error.message}`;
  }
}

This eliminates the error-prone pattern of checking multiple boolean flags like isLoading, isError, and hasData independently. A single discriminant property models exactly one state at a time.

Form State Machine

Forms have complex state transitions. Discriminated unions model them precisely.

type FormState =
  | { step: 'editing'; values: Record<string, string>; errors: Record<string, string> }
  | { step: 'validating'; values: Record<string, string> }
  | { step: 'submitting'; values: Record<string, string> }
  | { step: 'submitted'; confirmation: string }
  | { step: 'failed'; values: Record<string, string>; error: string };

type FormAction =
  | { type: 'UPDATE_FIELD'; field: string; value: string }
  | { type: 'SUBMIT' }
  | { type: 'VALIDATION_PASSED' }
  | { type: 'VALIDATION_FAILED'; errors: Record<string, string> }
  | { type: 'SUBMIT_SUCCESS'; confirmation: string }
  | { type: 'SUBMIT_ERROR'; error: string };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'UPDATE_FIELD':
      if (state.step !== 'editing') return state;
      return {
        step: 'editing',
        values: { ...state.values, [action.field]: action.value },
        errors: {},
      };

    case 'SUBMIT':
      if (state.step !== 'editing') return state;
      return { step: 'validating', values: state.values };

    case 'VALIDATION_PASSED':
      if (state.step !== 'validating') return state;
      return { step: 'submitting', values: state.values };

    case 'VALIDATION_FAILED':
      if (state.step !== 'validating') return state;
      return { step: 'editing', values: state.values, errors: action.errors };

    case 'SUBMIT_SUCCESS':
      if (state.step !== 'submitting') return state;
      return { step: 'submitted', confirmation: action.confirmation };

    case 'SUBMIT_ERROR':
      if (state.step !== 'submitting') return state;
      return { step: 'failed', values: state.values, error: action.error };
  }
}

Each state only carries the data relevant to it. You cannot access confirmation unless the form is in the 'submitted' state.

Event Systems

Discriminated unions are natural for typed event systems.

type AppEvent =
  | { type: 'USER_LOGIN'; userId: string; timestamp: number }
  | { type: 'USER_LOGOUT'; userId: string; timestamp: number }
  | { type: 'PAGE_VIEW'; path: string; referrer: string | null }
  | { type: 'PURCHASE'; productId: string; amount: number };

function trackEvent(event: AppEvent): void {
  switch (event.type) {
    case 'USER_LOGIN':
      console.log(`User ${event.userId} logged in`);
      break;
    case 'USER_LOGOUT':
      console.log(`User ${event.userId} logged out`);
      break;
    case 'PAGE_VIEW':
      console.log(`Page view: ${event.path}`);
      break;
    case 'PURCHASE':
      console.log(`Purchase: ${event.productId} for $${event.amount}`);
      break;
  }
}

Nested Discriminants

Complex domains sometimes need two levels of discrimination.

type Notification =
  | { channel: 'email'; to: string; subject: string; body: string }
  | { channel: 'sms'; to: string; message: string }
  | { channel: 'push'; deviceId: string; title: string; body: string };

type DeliveryResult =
  | { channel: 'email'; status: 'sent'; messageId: string }
  | { channel: 'email'; status: 'bounced'; reason: string }
  | { channel: 'sms'; status: 'delivered'; sid: string }
  | { channel: 'sms'; status: 'failed'; errorCode: number }
  | { channel: 'push'; status: 'delivered' }
  | { channel: 'push'; status: 'unregistered'; deviceId: string };

function handleResult(result: DeliveryResult): void {
  switch (result.channel) {
    case 'email':
      if (result.status === 'bounced') {
        console.log(`Email bounced: ${result.reason}`);
      }
      break;
    case 'sms':
      if (result.status === 'failed') {
        console.log(`SMS failed with code ${result.errorCode}`);
      }
      break;
    case 'push':
      if (result.status === 'unregistered') {
        console.log(`Device ${result.deviceId} unregistered`);
      }
      break;
  }
}

Utility: Extracting Variants

You can extract a specific variant from a discriminated union using the built-in Extract utility type.

type SuccessState = Extract<AsyncState<string[]>, { status: 'success' }>;
// { status: 'success'; data: string[] }

type ErrorState = Extract<AsyncState<string[]>, { status: 'error' }>;
// { status: 'error'; error: Error }

This is useful for function signatures that only accept certain variants.

function logError(state: Extract<AsyncState<unknown>, { status: 'error' }>): void {
  console.error(state.error.message);
}

Discriminant Best Practices

Use string literals for discriminant values. Numeric discriminants work but are less readable. Keep the discriminant property name consistent across your codebase. Common choices are type, kind, status, and tag.

Name your discriminant values in a way that reflects the domain. 'loading' is better than 'state2'. Use SCREAMING_CASE for event types and lowercase for state names, following the convention of your team.

Keep variants flat when possible. Deeply nested discriminated unions are hard to work with. If you find yourself nesting, consider splitting into separate union types.

Wrap Up

Discriminated unions model mutually exclusive states with precision. The compiler narrows types inside switch branches, exhaustive checks prevent unhandled cases, and each variant carries only the data it needs. Use them for async state, form flows, event systems, and any domain where a value can be in exactly one of several states at a time.