Going DeeperExtra· 40 min read

Discriminated Unions: Modelling Variants Safely

A discriminated union is the idiomatic TypeScript way to model "one of several shapes", using a shared tag field that tells the shapes apart.

What you will learn

  • Understand the problem plain unions of objects cause
  • Add a discriminant tag so TypeScript can narrow safely
  • Handle every case with a switch on the tag

The problem: a union of object shapes

Real apps constantly have a value that is "one of several kinds". An API response is either a success or an error. A shape is a circle, a square, or a rectangle. A payment is cash, card, or UPI. Each kind carries *different* data.

You might reach for a plain union of objects — but TypeScript cannot tell which shape you are holding, so it blocks you from touching the kind-specific fields. A discriminated union (also called a tagged union) fixes this with one simple trick.

The trick: a shared "tag" field

Give every member of the union a property with the same name but a different literal value — this is the discriminant (the tag). By convention it is often called kind or type. Because each tag value is unique, checking it tells TypeScript exactly which shape you have.

Each shape carries a unique kind tag
interface Circle {
  kind: 'circle';        // the tag
  radius: number;
}

interface Square {
  kind: 'square';        // the tag
  side: number;
}

type Shape = Circle | Square;

Note: Output: (No output — these are type definitions.) Both shapes share a kind property, but with different literal values ('circle' vs 'square'). That difference is what lets TypeScript narrow the union.

Switch on the tag and the fields unlock

Now write a function that takes a Shape. Inside a switch on kind, TypeScript narrows the value to exactly one member in each branch — so the right fields become available with full safety:

The kind tag narrows the union inside each case
function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // here TypeScript KNOWS shape is a Circle
      return Math.PI * shape.radius * shape.radius;
    case 'square':
      // here TypeScript KNOWS shape is a Square
      return shape.side * shape.side;
  }
}

console.log(area({ kind: 'circle', radius: 10 }).toFixed(2));
console.log(area({ kind: 'square', side: 5 }));

Note: Output: 314.16 100 In the 'circle' branch TypeScript narrows shape to Circle, so shape.radius is allowed. In the 'square' branch it is a Square, so shape.side is allowed. Try reading shape.side in the circle branch and TypeScript reports an error — the tag protects you.

A realistic example: an API result

This pattern is everywhere in real code. An API call either succeeds (with data) or fails (with a message). Model it as a tagged union and the caller is forced to handle both:

A success/error result modelled as a tagged union
type ApiResult =
  | { status: 'success'; data: string[] }
  | { status: 'error'; message: string };

function show(result: ApiResult): string {
  if (result.status === 'success') {
    return 'Loaded ' + result.data.length + ' items';
  }
  return 'Failed: ' + result.message;
}

console.log(show({ status: 'success', data: ['a', 'b', 'c'] }));
console.log(show({ status: 'error', message: 'Network down' }));

Note: Output: Loaded 3 items Failed: Network down Checking result.status === 'success' narrows to the success shape, so result.data exists there; the other branch has result.message. You can never accidentally read data on an error — TypeScript would reject it.

How to build one, step by step

  1. List every variant your value can be (e.g. circle, square; success, error).
  2. Give each variant a shared tag property with the same name (kind or status etc.).
  3. Give that tag a unique literal value per variant ('circle', 'square', ...).
  4. Join the variants with | into one union type.
  5. In your code, switch (or if) on the tag — each branch is automatically narrowed to one variant.

Tip: The discriminated union is the standard, idiomatic way to model state and variants in TypeScript. You will see it constantly: Redux actions (type: 'ADD' | type: 'REMOVE'), reducer states (status: 'loading' | 'loaded' | 'failed'), and API responses all use it.

Watch out: The tag must be a literal type ('circle'), not a plain string. If you write kind: string on every member, every member looks the same to TypeScript and narrowing stops working. Unique literals are what make the magic happen.

Q. What makes a union of object types a *discriminated* union?

Answer: A discriminated union gives every member a shared tag property (like kind) with a unique literal value, so checking that tag narrows the union to one exact member.

✍️ Practice

  1. Add a third member Rectangle { kind: 'rectangle'; width: number; height: number } to the Shape union and extend the area function.
  2. Model a Notification tagged union with members 'email' (to: string) and 'sms' (phone: string), and a function that returns a send message for each.

🏠 Homework

  1. Design a PaymentResult tagged union with 'paid' (amount: number) and 'declined' (reason: string) members. Write a function that returns a friendly message for each, and show both outputs.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →