Skip to content

Union Types and Discriminated Unions

intermediate12 min read

One Variable, Multiple Possible Types

In JavaScript, a variable can hold different types at different times. A function might return a string on success and null on failure. An API response might be a user object or an error object. TypeScript models this reality with union types.

But raw unions are clumsy. The real power comes from discriminated unions — a pattern that turns TypeScript's type system into a state machine verifier.

Mental Model

Think of a union type as a train station departure board. The board shows multiple possible destinations (types), but at any given moment, only one train is actually departing. TypeScript starts knowing it could be any destination. Narrowing is the act of reading the platform number to determine which specific train it is. Discriminated unions give every train a unique platform number (the discriminant property), so you always know which train you're on.

Basic Union Types

// A value that can be one of several types
type StringOrNumber = string | number;

function format(value: StringOrNumber): string {
  // Can't call .toUpperCase() — might be a number
  // Can't call .toFixed() — might be a string
  // Must narrow first
  if (typeof value === "string") {
    return value.toUpperCase(); // OK — narrowed to string
  }
  return value.toFixed(2); // OK — narrowed to number
}

// Union of literals — extremely useful for finite sets
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type LogLevel = "debug" | "info" | "warn" | "error";

Discriminated Unions — The Real Power

A discriminated union is a union where every member has a common property (the discriminant) with a unique literal type:

// Each state has a 'status' discriminant with a unique literal value
type RequestState =
  | { status: "idle" }
  | { status: "loading"; startedAt: number }
  | { status: "success"; data: unknown; duration: number }
  | { status: "error"; error: Error; retryCount: number };

function renderRequest(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Ready to fetch";
    case "loading":
      return `Loading since ${state.startedAt}`; // startedAt available
    case "success":
      return `Got data in ${state.duration}ms`; // data, duration available
    case "error":
      return `Error: ${state.error.message} (retry #${state.retryCount})`; // error, retryCount available
  }
}

After checking state.status, TypeScript knows exactly which variant you're in and which properties are available. No optional properties, no null checks, no casting.

How the compiler narrows discriminated unions

When TypeScript sees a check on a discriminant property (like state.status === "loading"), it performs control flow analysis. The compiler tracks which branches eliminate which union members:

  1. Before the check: state is RequestState (all four variants)
  2. In the "loading" case: state is { status: "loading"; startedAt: number } (only the loading variant)
  3. After the switch without a match: state is never (all variants eliminated)

This narrowing happens at every control flow branch — if/else, switch, ternary, early returns. The compiler maintains a narrowed type for each variable at each point in the code.

Discriminated Unions as State Machines

This is where discriminated unions shine in production. Instead of boolean flags and optional properties, model your state as a union:

// BAD — boolean soup
interface FormState {
  isSubmitting: boolean;
  isSuccess: boolean;
  isError: boolean;
  data?: ResponseData;
  error?: Error;
}
// Problem: isSubmitting && isSuccess — is that valid? TypeScript can't tell.
// Problem: data exists when isError is true? No compile-time protection.

// GOOD — discriminated union
type FormState =
  | { status: "idle" }
  | { status: "validating"; fields: Record<string, string> }
  | { status: "submitting"; fields: Record<string, string> }
  | { status: "success"; data: ResponseData }
  | { status: "error"; error: Error; fields: Record<string, string> };

// Impossible states are literally unrepresentable
// You can never have data AND error simultaneously
// You can never be submitting AND idle simultaneously
Common Trap

The "boolean soup" anti-pattern is the most common state modeling mistake in React applications. Multiple boolean flags create 2^n possible combinations, most of which are invalid. A discriminated union with n variants has exactly n valid states. If your component has isLoading, isError, isSuccess as separate booleans, refactor to a discriminated union immediately.

Exhaustive Checking with never

The never type ensures you handle every variant in a discriminated union:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default: {
      // If all cases are handled, shape is 'never' here
      const _exhaustive: never = shape;
      return _exhaustive;
    }
  }
}

// If someone adds a new variant:
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number }
  | { kind: "rectangle"; width: number; height: number }; // NEW

// The default case now ERROR:
// Type '{ kind: "rectangle"; width: number; height: number }' is not assignable to type 'never'
// ^ Forces you to add the missing case
The assertNever helper

Extract the exhaustive check into a reusable function for cleaner code:

function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}

// Usage
default:
  return assertNever(shape, `Unknown shape kind: ${(shape as any).kind}`);

This also provides a runtime safety net — if an impossible value somehow reaches the default case (e.g., from untyped JavaScript), it throws instead of silently returning undefined.

Production Scenario: API Response Handling

// Type-safe API responses with discriminated unions
type ApiResponse<T> =
  | { ok: true; data: T; status: number }
  | { ok: false; error: { code: string; message: string }; status: number };

async function fetchUser(id: string): Promise<ApiResponse<User>> {
  const res = await fetch(`/api/users/${id}`);
  const body = await res.json();

  if (res.ok) {
    return { ok: true, data: body as User, status: res.status };
  }
  return { ok: false, error: body, status: res.status };
}

// Caller gets exhaustive type safety
async function displayUser(id: string) {
  const result = await fetchUser(id);

  if (result.ok) {
    // result.data is User — no optional chaining needed
    console.log(result.data.name);
  } else {
    // result.error is { code: string; message: string }
    console.error(`${result.error.code}: ${result.error.message}`);
  }
}

// React component with discriminated union state
type UserPageState =
  | { phase: "loading" }
  | { phase: "loaded"; user: User; posts: Post[] }
  | { phase: "error"; message: string; retry: () => void }
  | { phase: "not-found" };

function UserPage({ state }: { state: UserPageState }) {
  switch (state.phase) {
    case "loading":
      return <Skeleton />;
    case "loaded":
      return <UserProfile user={state.user} posts={state.posts} />;
    case "error":
      return <ErrorBanner message={state.message} onRetry={state.retry} />;
    case "not-found":
      return <NotFoundPage />;
  }
}
Execution Trace
Union:
`result: ApiResponseUser`
Could be `( ok: true, data: User )` or `( ok: false, error: (...) )`
Check:
if (result.ok)
Discriminant check on the 'ok' property
True:
result narrowed to `( ok: true; data: User; status: number )`
result.data is User — no undefined, no null, no optional
False:
result narrowed to `( ok: false; error: (...); status: number )`
result.error is guaranteed to exist — impossible to access data here
Common Mistakes
  • Wrong: Modeling state with multiple booleans: isLoading, isError, isSuccess Right: Use a discriminated union with a single status field

  • Wrong: Forgetting the exhaustive never check in switch statements Right: Always add a default case that assigns to never

  • Wrong: Using optional properties instead of separate union variants Right: Put properties on only the variants where they exist

  • Wrong: Using string as the discriminant type instead of string literals Right: Use literal types: status: 'loading' not status: string

Quiz
What type does state.data have inside the 'success' case of a switch on state.status?
Quiz
Why does the never exhaustive check work?
Quiz
Given: type A = { x: 1; a: string } | { x: 2; b: number } — what properties can you access without narrowing?

Refactor Boolean Soup to Discriminated Union

Show Answer
type ModalState =
  | { phase: "closed" }
  | { phase: "opening"; content: React.ReactNode }
  | { phase: "open"; content: React.ReactNode }
  | { phase: "confirming"; content: React.ReactNode; onConfirm: () => void }
  | { phase: "error"; content: React.ReactNode; error: string }
  | { phase: "closing" };

Now every state is explicit. You cannot have content without the modal being open. You cannot be confirming without a confirm handler. You cannot have an error on a closed modal. The type system enforces the valid state transitions, and every switch on phase gives you exactly the properties available in that state.

Key Rules
  1. 1Discriminated unions model state machines — each variant has a discriminant literal and only the properties valid for that state
  2. 2Always use exhaustive checking (never in default) to catch unhandled variants at compile time
  3. 3Replace boolean soup (isLoading, isError, isSuccess) with a single discriminated union — impossible states become unrepresentable
  4. 4Union members can only be accessed by their shared properties — narrow before accessing variant-specific properties
  5. 5The discriminant must be a literal type (not just string/number) for narrowing to work