CodeOath
← All posts
TypeScript75 min total · 21 parts

TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime

Contents — Part 7 of 21: Discriminated Unions
Part 7 of 21 · ~1 min

Discriminated Unions

A discriminated union is a union of object types that all share one common literal-typed field (the "discriminant" or "tag"), used to narrow the whole object at once instead of checking individual properties:

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string };
type State = LoadingState | SuccessState | ErrorState;

function render(state: State) {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.join(", "); // narrowed to SuccessState — .data exists here
    case "error":
      return `Error: ${state.message}`; // narrowed to ErrorState
  }
}

This is one of the most valuable patterns in real TypeScript code, because it makes impossible states unrepresentable — there's no way to construct a value that's simultaneously "success" and missing data, unlike a single flat object with an isLoading: boolean and an optional data?: string[] where several fields could disagree with each other. Combined with a switch on the discriminant, TypeScript can also verify exhaustiveness — that every case is handled — using a common trick:

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

function render2(state: State): string {
  switch (state.status) {
    case "loading": return "Loading...";
    case "success": return state.data.join(", ");
    case "error": return `Error: ${state.message}`;
    default: return assertNever(state); // compile error if a case was added to State but not handled here
  }
}

If someone later adds a fourth variant to State and forgets to handle it above, state in the default branch is no longer narrowed to never, and the call to assertNever fails to compile — catching the omission at build time instead of at runtime.