Discriminated unions

TypeScript · Practical · 12 min

This is the single most useful modelling pattern in TypeScript: it makes illegal states unrepresentable.

A discriminated union is a union of object types that all share a property — the discriminant or tag — whose type is a different literal in each member:

type Result =
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: string[] };

Checking the tag narrows the whole object: inside if (r.status === "success") TypeScript knows r.data exists, and r.error is not even accessible. A switch on the tag is the idiomatic way to handle every case, ideally with an exhaustiveness check in default (assign to never) so that adding a new variant breaks the build in every place that must handle it.

Why this beats a single "bag of optionals" like { loading: boolean; error?: Error; data?: string[] }: that type permits nonsense states (loading and errored, with data) and forces every reader to guess which combinations are real. The union lists exactly the legal states and the data each one carries.

Tips:

  • Use string literals for tags; kind, type and status are common names. Booleans work too (ok: true / ok: false).
  • Destructuring the tag into a separate const before checking still narrows, as long as both are const.
  • The same pattern models messages, actions in reducers, AST nodes, events and API responses.
  • Libraries that validate data at runtime (Zod, Valibot) can produce these unions from schemas.

A Result-style type for errors — { ok: true; value: T } | { ok: false; error: E } — is a lightweight way to make failure explicit in function signatures.

Example

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: 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 "rect": return shape.width * shape.height;
    default: {
      const unhandled: never = shape;   // compile error if a kind is missed
      throw new Error(`Unknown shape: ${JSON.stringify(unhandled)}`);
    }
  }
}

type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function parseAge(input: string): Result<number> {
  const n = Number(input);
  return Number.isInteger(n) && n >= 0 ? { ok: true, value: n } : { ok: false, error: `bad age: ${input}` };
}
const r = parseAge("42");
console.log(r.ok ? r.value + 1 : r.error, area({ kind: "rect", width: 2, height: 5 }));

Remember

  • Give each variant a literal tag; checking the tag narrows the whole object
  • Add a never check in default to catch unhandled variants
  • Prefer unions of states over objects full of optional fields

Watch out for

  • Making the tag a plain string instead of a literal — then nothing narrows.

Check yourself

  1. In type R = { ok: true; value: number } | { ok: false; error: string }, what can you access after if (!r.ok)?
  2. What does assigning the switch variable to a never-typed constant in default achieve?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions

Builds on

Unlocks

Nothing builds on this yet.