RizTech Academy logo
RizTech Academy
Unions and NarrowingLesson 3 of 530 min

Discriminated unions, the best pattern in the language

This is, for many experienced developers, the single most valuable pattern in TypeScript. A discriminated union models "a value that is one of several kinds, each carrying its own data", in a way the compiler can check completely — narrowing to the right kind, and proving you handled every one. If you learn one thing from this course to reach for constantly, make it this.

The pattern

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

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

Three object types, each with a kind property set to a specific literal ("circle", "rectangle", "triangle"), and each carrying the data that that shape needs — a circle has a radius, a rectangle has width and height. The shared kind is the discriminant: it is how you (and the compiler) tell which member you have.

Narrowing by the discriminant

Checking the discriminant narrows the whole object to the matching member, unlocking its specific properties:

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;        // narrowed to circle — radius available
    case "rectangle":
      return shape.width * shape.height;          // narrowed to rectangle — width/height available
    case "triangle":
      return (shape.base * shape.height) / 2;     // narrowed to triangle
  }
}

Inside case "circle":, TypeScript knows shape is the circle member, so shape.radius is available and shape.width would be an error. The kind literal acts as a key that unlocks exactly the right properties. This is narrowing (last lesson) applied through a switch on the discriminant, and it is beautifully clean — no instanceof, no in, no type guards, just checking one property.

Try to access the wrong property and TypeScript stops you before you narrow:

function broken(shape: Shape): number {
  return shape.radius;    // error: Property 'radius' does not exist on type 'Shape'
                          // (only the circle member has it — you must narrow first)
}

shape.radius is an error because shape might be a rectangle or triangle, which have no radius. The union forces you to check kind before touching kind-specific data — which is exactly the safety you want.

Why it is the best pattern

Discriminated unions solve a problem every program has — "this thing is one of several variants" — and solve it completely:

Illegal states become unrepresentable. With a discriminated union, a "circle" simply cannot have a width — the type does not allow it. Compare with a single flat type where everything is optional:

// the bad alternative — everything optional, illegal states allowed:
type Shape = { kind: string; radius?: number; width?: number; height?: number; base?: number };
// nothing stops { kind: "circle", width: 5 } — a nonsensical circle with a width and no radius

The flat version permits nonsense: a circle with a width, a rectangle with a radius, a shape with none of the properties. The discriminated union permits only valid combinations. This is the course's recurring theme — make illegal states unrepresentable — and discriminated unions are the primary tool for it.

The compiler narrows for you. No manual type guards, no casting — checking the discriminant does all the narrowing.

Exhaustiveness can be checked. The compiler can prove you handled every variant — the next lesson.

The canonical uses

You will reach for this pattern constantly:

Result / success-or-failure — the most useful of all, a type-safe alternative to throwing:

type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: string };

function parseAge(input: string): Result<number> {
  const n = Number(input);
  if (Number.isNaN(n)) return { ok: false, error: `"${input}" is not a number` };
  return { ok: true, value: n };
}

const result = parseAge("33");
if (result.ok) {
  console.log(result.value);    // narrowed — value available, error is not
} else {
  console.log(result.error);    // narrowed — error available
}

Checking result.ok narrows to the success or failure member, so you can only access value on success and error on failure — the compiler enforces that you handle both. This is the errors-typed pattern from the best-practices module, and it is a discriminated union at heart.

UI / async state — the React and front-end staple:

type State<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

Events / actions — a fixed set of things that can happen, each with its own payload:

type Action =
  | { type: "increment"; by: number }
  | { type: "reset" }
  | { type: "setName"; name: string };

Every one of these is "one of a fixed set of kinds, each with its own data", and the discriminant (ok, status, type) is what narrows. When you see switch (action.type) in a Redux reducer or if (state.status === "loading") in a component, you are looking at a discriminated union.

Designing one well

Two small conventions make discriminated unions work smoothly:

  • Use a consistent discriminant name across your codebase — kind, type, status, or tag. Pick one and stick with it so the pattern is instantly recognisable.
  • Make the discriminant a string literal, not a boolean or number, when there are more than two cases — "circle" | "rectangle" reads far better than 1 | 2, and it logs and debugs clearly. (For exactly two cases, a boolean discriminant like ok: true | false is fine and common.)

Get in the habit of asking, whenever you model data: is this "one of several kinds"? Then it is a discriminated union. It will be the answer more often than you expect, and reaching for it — instead of a flat type with a pile of optional properties — is one of the clearest marks of someone who thinks in types.

Check your work

What a discriminated union is. A union of object types sharing a common discriminant property whose value is a different literal in each member.

What the discriminant does. Checking it narrows the whole object to the matching member, unlocking that member's specific properties.

Why you cannot access a member-specific property before narrowing. The value might be another member that lacks it — you must check the discriminant first.

The core benefit. Illegal states become unrepresentable — only valid property combinations exist, unlike a flat type with everything optional.

Two more benefits. The compiler narrows for you (no manual guards), and exhaustiveness can be checked.

Three canonical uses. Result (success/failure), UI/async state (loading/success/error), and events/actions.

Two design conventions. A consistent discriminant name (kind/type/status), and a string literal discriminant for more than two cases.

The question to ask when modelling. Is this "one of several kinds"? Then it is a discriminated union.

Practice

  1. Write the Shape discriminated union and an area function that switches on kind. Confirm each case has the right properties.
  2. Try to access shape.radius without narrowing and read the error. Then narrow and confirm it works.
  3. Contrast with a flat Shape type where everything is optional. Construct a nonsensical value (a circle with a width) and note the flat type allows it but the discriminated union does not.
  4. Write a Result<T> union and a parseAge returning it. Handle both ok cases and confirm value and error are only available in the right branch.
  5. Write a State<T> union for loading/success/error and a function that renders each state.
  6. Write an Action union with three action types and a function that handles each.
  7. Take a flat type with several optional properties from code you know and redesign it as a discriminated union.

Official documentation

Next: writing your own type guards.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship