Q19 of 38 · TypeScript

What is a discriminated union in TypeScript, and why is it useful for modelling API responses?

TypeScriptMidtypescriptdiscriminated-unionunion-typesapi-typesexhaustive-checks

Short answer

Short answer: A discriminated union is a union type where every member has a shared literal property (`kind`, `type`, `status`) that uniquely identifies it. TypeScript uses that discriminant to narrow the union in switch/if blocks, giving you type-safe access to variant-specific properties without type assertions.

Detail

A discriminated union (also called a tagged union or algebraic data type) is a pattern where each member of a union type has a discriminant property — a literal type value that uniquely identifies the variant.

Structure:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };

The kind field is the discriminant. TypeScript knows that if kind === "circle", radius is available.

Why it's powerful:

  • TypeScript narrows automatically in switch/if on the discriminant
  • No type assertions needed
  • Adding a new variant causes compile errors at all unhandled switch cases (with never exhaustive check)
  • Reads like documentation — the variant's type makes intent clear

API response modelling: The { status: "ok"; data: T } | { status: "error"; message: string } pattern is the canonical use in test automation. It forces handlers to deal with both outcomes explicitly, unlike a nullable data? that can be silently ignored.

In Playwright fixtures: Custom fixture types that vary by authentication state, user role, or environment can be modelled as discriminated unions to provide type-safe access to the correct fixture properties.

// EXAMPLE

// Discriminated union for API results
type ApiResult<T> =
  | { status: "ok"; data: T }
  | { status: "error"; code: number; message: string }
  | { status: "loading" };

function render<T>(result: ApiResult<T>): string {
  switch (result.status) {
    case "ok":
      return JSON.stringify(result.data); // data: T, type-safe
    case "error":
      return `Error ${result.code}: ${result.message}`; // code and message available
    case "loading":
      return "Loading...";
    default:
      const _: never = result; // exhaustive check
      throw new Error("Unexpected status");
  }
}

// Shape example
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(s: Shape): number {
  if (s.kind === "circle") return Math.PI * s.radius ** 2;
  return s.width * s.height; // TypeScript knows it's rect
}

// WHAT INTERVIEWERS LOOK FOR

The discriminant property structure and how TypeScript narrows it. The exhaustive check with `never`. Modelling API results as discriminated unions shows practical maturity — it prevents the common bug of accessing `.data` without checking the status.

// COMMON PITFALL

Using optional properties instead of a discriminated union — `{ data?: T; error?: string }` allows impossible states (both set, or neither set) and requires checking both fields everywhere. Discriminated unions make impossible states unrepresentable.