Skip to content
Theory · Narrowing · lesson 7 of 12

Discriminated unions

Sorting on the belt can be made simpler still. When a parcel is sent, a label with one word is stuck on it: "envelope" or "can". The arch no longer has to examine the parcel and look for marks. It reads the label and knows at once what is inside and which chute to send it to.

Objects in a program are told apart the same way. A shape is either a circle with a radius r or a square with a side side. You cannot read side from any shape:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function area(s: Shape) {
    return s.side ** 2; // ✗
}
TEXT
error TS2339: Property 'side' does not exist on type 'Shape'.
  Property 'side' does not exist on type '{ kind: "circle"; r: number; }'.

A circle has no side, and s may hold a circle. The check "side" in s would help, but TypeScript has a handier technique: a label inside the object itself. This example already has one: the kind field.

A shared field with a literal type

Every member of the union declares the same field kind, and in each member it has its own literal type: "circle" for the circle, "square" for the square. Such a union is called a discriminated union, and the shared field is its discriminant. Comparing the discriminant with a literal narrows the type:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function area(s: Shape): number {
    if (s.kind === "circle") {
        return Math.PI * s.r ** 2;
    }
    return s.side ** 2;
}

console.log(area({ kind: "square", side: 3 })); // 9

Let's walk through the example line by line:

  1. In type Shape, each member has its own value of kind. You may also put a vertical bar before the first member: this way a long union is convenient to write as a column.
  2. s.kind === "circle" compares the discriminant with a literal. Only a circle has the value "circle", so inside the if, s has the circle type, and s.r compiles.
  3. After the return, the square is left, and s.side compiles too.
  4. The program prints 9.

The main point is that checking one field narrows the whole object. Once tsc knows kind, it knows the member's other fields too: a circle has r, a square has side.

switch on the discriminant

When there are several members, a switch is a handy way to go through the discriminant. Each case narrows the object to its own member:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
    }
}

console.log(label({ kind: "circle", r: 2 })); // circle, r = 2

Inside case "circle", s has the circle type, and inside case "square" the square type. TypeScript sees that the two cases cover every member, and it infers the result type string for label. The program prints circle, r = 2.

Interviewers like to ask what a discriminated union is and how you narrow one. The answer: all members have a shared field with different literal types, and checking that field in an if or a switch narrows the whole object.

Here the label picture parts ways with TypeScript. A sender can write a real label wrong, and the sorting will trust it. tsc checks the discriminant: the object { kind: "triangle", side: 3 } does not pass as a Shape, because "triangle" does not fit the type "circle" | "square". And at run time the discriminant is an ordinary field of the object, so the comparison s.kind === "circle" works in JavaScript too.

Common mistake

The discriminant is typed string:

TypeScript
type Shape =
    | { kind: string; r: number }
    | { kind: string; side: number };

function area(s: Shape): number {
    if (s.kind === "circle") {
        return Math.PI * s.r ** 2; // ✗
    }
    return 0;
}
TEXT
error TS2339: Property 'r' does not exist on type 'Shape'.

In both members the discriminant is any string, and comparing it with "circle" says nothing about the member: the string "circle" fits both. tsc cannot narrow the object and does not let you read s.r. A discriminant needs literal types, different in each member: kind: "circle" and kind: "square".

Key points

  • In a discriminated union, all members share a discriminant field, and each member gives it its own literal type.
  • Checking the discriminant in an if or a switch narrows the whole object, together with the member's fields.
  • The discriminant is an ordinary field: it stays in the object at run time, and the comparison works in JavaScript.
  • A discriminant of type string gives no narrowing: it needs different literal types.