Course outline · 0% complete

0/26 lessons0%

Course overview →

Discriminated Unions

lesson 4-3 · ~13 min · 13/26

One shared tag property

The production-grade pattern for "data that comes in several shapes" is the discriminated union: every shape carries the same literal-typed property, called the tag or discriminant, and you branch on it.

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

Each member declares kind with a different literal type from lesson 2-3. Checking the tag narrows perfectly:

if (shape.kind === "circle") {
  // shape is the circle member, radius exists
}

You will meet this pattern constantly in real codebases. A web API response that is either data or an error, a user action in a front-end app, a message arriving from another program: each is one union with a tag saying which shape arrived.

An if on the tag works for two members. Past that, the same check reads best as a switch on the tag. Writing switch (shape.kind) compares the tag against each case with === and runs the matching branch, and TypeScript narrows inside each case exactly as it does inside an if.

switch (shape.kind) "circle" radius: number width: not in reach "rectangle" width, height radius: not in reach "square" side: number others: not in reach All three cases together cover the union, so no default branch is needed. Add a fourth member and this function stops compiling until it is handled.
Each case of the switch compares the shared tag, and inside that branch only one member's properties are in reach.

Two shapes, one tag check

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

function area(shape: Shape): number {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius * shape.radius;
  }
  return shape.width * shape.height;
}

console.log(area({ kind: "rectangle", width: 3, height: 4 }));
console.log(area({ kind: "circle", radius: 1 }).toFixed(2));

Output

12
3.14

Reading shape.radius before the if is blocked by the compiler, and the reason is precise. Before the check, shape could be either member, and the only property both members share is kind. So kind is the only property readable at that point, which is exactly why the tag has to be the thing you check.

After the check succeeds, TypeScript knows the value is the circle member and radius comes into reach. After the if, only the rectangle member remains, so width and height become readable instead.

Scaling to three members with a switch

An if on the tag works for two members. Past that, the same check reads better as a switch. Here is the three-member version.

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

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius * shape.radius;
    case "rectangle":
      return shape.width * shape.height;
    case "square":
      return shape.side * shape.side;
  }
}

console.log(area({ kind: "square", side: 5 }));
console.log(area({ kind: "rectangle", width: 2, height: 8 }));

Output

25
16

What the switch buys you

  • Each case narrows shape to exactly one member, so the right properties are in reach per branch. Reading shape.side inside the "circle" case is rejected, because a circle has no side.
  • Every case returns, so no break statements are needed here.
  • The compiler accepts the function with no default branch and no final return, because the three cases cover every member of the union. That is a genuinely useful signal: add a fourth member to Shape later and this function starts failing to compile until you handle it, so the type system points you at every place that needs updating.

What makes a union discriminated

A union is discriminated when every member shares one property whose type is a different literal value in each member.

That shared literal-typed property is the tag, or discriminant. In the Shape union it is kind, holding "circle" in one member and "rectangle" in another. Because no two members can carry the same tag value, comparing the tag against one literal tells the compiler exactly which member you hold, and every other property of that member narrows into reach.

You will meet this pattern constantly in real codebases: a web API response that is either data or an error, a user action in a front-end app, a message arriving from another program. Each is one union with a tag saying which shape arrived.

describeResult: modelling success and failure

LoadResult is a discriminated union with a status tag: one member carries a numeric value, the other carries an error message. This is the single most common real-world use of the pattern.

type LoadResult =
  | { status: "ok"; value: number }
  | { status: "error"; message: string };

function describeResult(r: LoadResult): string {
  if (r.status === "ok") {
    return "value is " + r.value;
  }
  return "failed: " + r.message;
}

console.log(describeResult({ status: "ok", value: 7 }));
console.log(describeResult({ status: "error", message: "not found" }));

Output

value is 7
failed: not found

Reading the union

  • The tag here is status, compared with === against the literal "ok".
  • Inside the ok branch r.value exists, and in the other branch r.message exists. Neither is reachable in the wrong branch, which makes it impossible to read a value out of a failed result.
  • Notice what this rules out by construction. A single shape with optional value and optional message would allow nonsense combinations, such as a success with no value or an error carrying both. The union permits exactly the two states that make sense.