⚡IntermediateTypeScript in Practice

Discriminated Unions (Advanced)

Model state machines, request/response cycles, and complex domain states using exhaustive discriminated unions and the satisfies operator.

Overview

You already know that discriminated unions use a shared literal property (the "discriminant") to allow TypeScript to narrow across union members. At the intermediate level, the power of this pattern becomes clear when applied to **state machines**, **async state tracking**, and **event systems**. The key advanced techniques are: **Exhaustive checking** - using `never` or the `satisfies` operator to guarantee every case is handled at compile time, preventing silent bugs when new states are added. **Payload-typed events** - an event system where each event name is tied to a specific payload type, enabling type-safe dispatching and subscribing. **State machine types** - encoding the valid states and transitions of UI state (`idle`, `loading`, `success`, `error`) so that TypeScript prevents you from accessing `data` before the request succeeds or `error` when the state is still `loading`.

Key Points

  • •Discriminant property: a literal-typed field (`type`, `kind`, `status`) shared by all union members
  • •TypeScript uses the discriminant to narrow to the specific member in if/switch blocks
  • •Exhaustive switch: add `default: const _: never = x` to catch unhandled variants at compile time
  • •`satisfies` operator (TS 4.9+): `x satisfies MyType` validates type without widening
  • •State machine pattern: encode all valid states as discriminated union members
  • •Async state: `type AsyncState<T> = Idle | Loading | Success<T> | Failure` models fetch lifecycle
  • •Tagged event system: `type AppEvent = { type: "login"; user: User } | { type: "logout" }` - type-discriminated events

Examples

Async fetch state machine

interface User { id: number; name: string }

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

function renderUser(state: FetchState<User>): string {
  switch (state.status) {
    case 'idle':
      return 'Click to load user';
    case 'loading':
      return 'Loading...';
    case 'success':
      // TypeScript knows state.data is User here
      return `Welcome, ${state.data.name}!`;
    case 'error':
      // TypeScript knows state.error is Error here
      return `Error: ${state.error.message}`;
    default:
      // Exhaustive check  -  TypeScript errors if a case is missing
      const _: never = state;
      throw new Error('Unhandled state');
  }
}

let state: FetchState<User> = { status: 'idle' };
console.log(renderUser(state)); // "Click to load user"

state = { status: 'loading' };
console.log(renderUser(state)); // "Loading..."

state = { status: 'success', data: { id: 1, name: 'Alice' } };
console.log(renderUser(state)); // "Welcome, Alice!"

state = { status: 'error', error: new Error('404 Not Found') };
console.log(renderUser(state)); // "Error: 404 Not Found"

Type-safe event bus with discriminated union

// All events are a discriminated union on "type"
type AppEvent =
  | { type: 'user:login';   user: { id: number; name: string } }
  | { type: 'user:logout' }
  | { type: 'cart:add';     productId: number; quantity: number }
  | { type: 'cart:clear' }
  | { type: 'order:placed'; orderId: number; total: number };

// Generic event dispatcher
type EventType = AppEvent['type'];
type EventPayload<T extends EventType> = Extract<AppEvent, { type: T }>;

class EventBus {
  private handlers = new Map<string, Set<(e: AppEvent) => void>>();

  on<T extends EventType>(
    type: T,
    handler: (event: EventPayload<T>) => void
  ): void {
    if (!this.handlers.has(type)) this.handlers.set(type, new Set());
    this.handlers.get(type)!.add(handler as (e: AppEvent) => void);
  }

  emit<T extends EventType>(event: EventPayload<T>): void {
    this.handlers.get(event.type)?.forEach(h => h(event));
  }
}

const bus = new EventBus();

// Handlers are fully typed  -  no casting
bus.on('user:login', ({ user }) => {
  console.log('Logged in:', user.name); // user is { id: number; name: string }
});

bus.on('cart:add', ({ productId, quantity }) => {
  console.log(`Added ${quantity}x product #${productId}`);
});

bus.emit({ type: 'user:login', user: { id: 1, name: 'Alice' } });
bus.emit({ type: 'cart:add', productId: 42, quantity: 2 });

Using satisfies for exhaustive config validation

type Theme = 'light' | 'dark' | 'system';
type Language = 'en' | 'fr' | 'de' | 'ja';

interface ThemeConfig {
  background: string;
  foreground: string;
  accent: string;
}

// satisfies validates each key against ThemeConfig without widening the type
const themeMap = {
  light:  { background: '#ffffff', foreground: '#000000', accent: '#3b82f6' },
  dark:   { background: '#0f172a', foreground: '#f8fafc', accent: '#60a5fa' },
  system: { background: 'inherit', foreground: 'inherit', accent: '#3b82f6' },
} satisfies Record<Theme, ThemeConfig>;

// TypeScript still knows the exact string literals (not widened to string)
console.log(themeMap.dark.background); // "#0f172a"  -  precise type, not string

// Pattern: ensure all cases in a map are handled
type Action =
  | { type: 'increment'; amount: number }
  | { type: 'decrement'; amount: number }
  | { type: 'reset' };

type Reducer<S, A> = (state: S, action: A) => S;

const counterReducer: Reducer<number, Action> = (state, action) => {
  switch (action.type) {
    case 'increment': return state + action.amount;
    case 'decrement': return state - action.amount;
    case 'reset':     return 0;
    default:
      const _: never = action;
      return state;
  }
};

console.log(counterReducer(10, { type: 'increment', amount: 5 })); // 15
console.log(counterReducer(10, { type: 'reset' }));               // 0

Common Mistakes

  • Using a non-literal discriminant type (e.g., `type: string`) - TypeScript cannot narrow a union by a string-typed property; discriminants must be literal types like `"success"` or `42`
  • Forgetting the exhaustive default case - adding a new union member later will silently go unhandled unless you have the `never` check
  • Making the discriminant optional (`type?: string`) - TypeScript requires the discriminant to be present on every member to narrow correctly

Note

Model your application's async states as `{ status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error }`. This pattern prevents the "loading but also has data" impossible state that plain booleans (`isLoading`, `isError`, `data`) allow. If it can't exist, it can't be expressed.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 3 exercises

Knowledge Check

Question 1 of 4

What must the discriminant property of a discriminated union be typed as?