Skip to content
aviral gupta

// B3.4 · ~30 min · Beginner

Discriminated unions

After this lesson you can turn a type full of optional properties into a discriminated union, narrow it with if, switch, grouped cases and result.ok, and destructure it without losing the narrowing.

Lesson 4 of 5 in B3 Narrowing

You will be able to

  • Explain why one type with optional properties cannot be narrowed, and model each case as its own member
  • Narrow a discriminated union with ===, switch, grouped cases and boolean discriminants such as result.ok
  • Keep the narrowing when you destructure the discriminant or store a check in a constant
  1. Warm-up · Activity 1 of 7

    Warm-up from JavaScript: some cases below have no break. What does this print?

    const kind: string = "image";
    const out: string[] = [];
    switch (kind) {
      case "text":
        out.push("text");
      case "image":
      case "attachment":
        out.push("media");
      case "link":
        out.push("link");
        break;
      default:
        out.push("other");
    }
    console.log(out.join(" "));
  2. Predict · Activity 2 of 7

    Predict before you read on. This is the Handbook's first attempt at a Shape type. What does tsc say about getArea?

    interface Shape {
      kind: "circle" | "square";
      radius?: number;
      sideLength?: number;
    }
    
    function getArea(shape: Shape) {
      if (shape.kind === "circle") {
        return Math.PI * shape.radius ** 2;
      }
      return 0;
    }
  3. Practice · Activity 3 of 7

    Fill in the type of ok in the second member, so that if (result.ok) narrows and the program prints sent #7 and failed: offline.

    type Sent = { ok: true; id: number } | { ok: ____; error: string };
    
    function report(result: Sent): string {
      if (result.ok) {
        return "sent #" + result.id;
      }
      return "failed: " + result.error;
    }
    
    console.log(report({ ok: true, id: 7 }));
    console.log(report({ ok: false, error: "offline" }));
    | { ok: ; error: string };
  4. Practice · Activity 4 of 7

    With the types below, match each situation to the type m or r has at that point.

    type Text = { type: "text"; body: string };
    type Image = { type: "image"; url: string; width: number };
    type Attachment = { type: "attachment"; name: string; bytes: number };
    type Message = Text | Image | Attachment;
    type Ok = { ok: true; id: number };
    type Failed = { ok: false; error: string };
    type Sent = Ok | Failed;
    
  5. Practice · Activity 5 of 7

    One case has no break. Which line does tsc reject?

    type Text = { type: "text"; body: string };
    type Image = { type: "image"; url: string; width: number };
    type Attachment = { type: "attachment"; name: string; bytes: number };
    type Message = Text | Image | Attachment;
    
    function label(m: Message): string {
      let text = "";
      switch (m.type) {
        case "text":
          text = m.body;
        case "image":
          text += m.url;
          break;
        case "attachment":
          text = m.name;
      }
      return text;
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. error is an Error or null, and data is null or a string array. Neither is a string literal. What does tsc report for names?

    type Fetched = { error: Error; data: null } | { error: null; data: string[] };
    
    function names(r: Fetched): number {
      if (r.error) throw r.error;
      return r.data.length;
    }
  7. Apply · Activity 7 of 7

    Mini-task. A smart home reports three kinds of events: motion in a room, a temperature in a room, and the front door opening or closing. Model them as a discriminated union HomeEvent, and write describe(e) that reads each member's data only after checking kind. Use stacked cases for the two events that have a room. Then write readCelsius(room, raw), which returns { ok: true; event } or { ok: false; error } for values outside -40 to 85, and print each result after checking ok. Check it in the editor, or with npx tsc and node main.ts.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A chat outbox

Three kinds of messages share a type property with a different literal in each member. preview stacks the two media cases, copies the discriminant into a const to tell them apart, and leaves Text to default. send reports with the boolean discriminant ok. Run npx tsc, then node main.ts. Then change const { type } to let { type } and run tsc again.

main.ts

// A chat client: one object type per kind of message, told apart by "type".
type Text = { type: "text"; body: string };
type Image = { type: "image"; url: string; width: number };
type Attachment = { type: "attachment"; name: string; bytes: number };
type Message = Text | Image | Attachment;

// A boolean discriminant: ok says which of the two you have.
type Sent = { ok: true; id: number } | { ok: false; error: string };

function preview(m: Message): string {
  switch (m.type) {
    case "image":
    case "attachment": {
      // m is Image | Attachment here
      const { type } = m; // a const copy of the discriminant still narrows m
      if (type === "image") return "[image, " + m.width + " px]";
      return "[file " + m.name + ", " + Math.ceil(m.bytes / 1024) + " KB]";
    }
    default:
      // only Text is left
      return '"' + (m.body.length > 20 ? m.body.slice(0, 20) + "..." : m.body) + '"';
  }
}

let nextId = 1;

function send(m: Message): Sent {
  if (m.type === "text" && m.body.trim() === "") return { ok: false, error: "empty text" };
  if (m.type === "attachment" && m.bytes > 5_000_000) return { ok: false, error: "file over 5 MB" };
  return { ok: true, id: nextId++ };
}

const outbox: Message[] = [
  { type: "text", body: "Are we still on for Friday at noon?" },
  { type: "image", url: "/pics/cat.png", width: 640 },
  { type: "text", body: "   " },
  { type: "attachment", name: "notes.pdf", bytes: 48_213 },
  { type: "attachment", name: "film.mp4", bytes: 912_000_000 },
];

for (const m of outbox) {
  const result = send(m);
  if (result.ok) {
    console.log("#" + result.id + " " + preview(m));
  } else {
    console.log("not sent (" + result.error + "): " + preview(m));
  }
}

Run it with

npx tsc
node main.ts

Output

#1 "Are we still on for ..."
#2 [image, 640 px]
not sent (empty text): "   "
#3 [file notes.pdf, 48 KB]
not sent (file over 5 MB): [file film.mp4, 890625 KB]
  • With let { type } = m, tsc reports m.width and m.name: a let could change before the if, so it no longer narrows m.
  • In default, only Text is left, so m.body needs no check.
  • result.id exists only after if (result.ok); in the else branch, result.error is the one to read.
  • No property is optional and there is no ! anywhere: each member says exactly what it has.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Split the delivery type

Delivery is one type with optional properties, so tsc reports error TS18048: 'd.store' is possibly 'undefined', although label checks method first. Do not touch label. Replace Delivery with a discriminated union of two exported types, Pickup (a store) and Courier (an address and a floor), each with its data required.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    The check in label is fine. The type has to say that a pickup always has a store, and a courier delivery always has an address and a floor.

  2. Hint 2

    Write two object types, each with method set to one literal, "pickup" or "courier", and no question marks.

  3. Hint 3

    export type Pickup = { method: "pickup"; store: string }; then Courier the same way, and export type Delivery = Pickup | Courier;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export type Pickup = { method: "pickup"; store: string };
export type Courier = { method: "courier"; address: string; floor: number };
export type Delivery = Pickup | Courier;

export function label(d: Delivery): string {
  if (d.method === "pickup") {
    return "Pick up at " + d.store.toUpperCase();
  }
  return "Courier to " + d.address + ", floor " + d.floor.toFixed(0);
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export type Delivery = {
  method: "pickup" | "courier";
  store?: string;
  address?: string;
  floor?: number;
};

export function label(d: Delivery): string {
  if (d.method === "pickup") {
    return "Pick up at " + d.store.toUpperCase();
  }
  return "Courier to " + d.address + ", floor " + d.floor.toFixed(0);
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {label} from './main.ts';

test('a pickup names the store in capitals', () => {
  const text = label({method: 'pickup', store: 'Mitte'});
  assert.equal(text, 'Pick up at MITTE', `label of a pickup gave ${JSON.stringify(text)}`);
});

test('a courier delivery names the address and the floor', () => {
  const text = label({method: 'courier', address: 'Hauptstr. 5', floor: 3});
  assert.equal(text, 'Courier to Hauptstr. 5, floor 3', `label of a courier delivery gave ${JSON.stringify(text)}`);
});

test('the ground floor is floor 0', () => {
  const text = label({method: 'courier', address: 'Ring 1', floor: 0});
  assert.equal(text, 'Courier to Ring 1, floor 0', `label of a ground-floor delivery gave ${JSON.stringify(text)}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 2 of 2

Check ok before you read the port

parsePort returns a PortResult. describePort reads result.port straight away, and tsc reports error TS2339: Property 'port' does not exist on type 'PortResult'. Make describePort return "port 8080" for "8080", and "invalid: " followed by the reason when parsing fails.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    PortResult has two members, and only the one with ok: true has a port. What must you check first?

  2. Hint 2

    if (!result.ok) narrows result to the member with reason. After a return in that branch, only the member with port is left.

  3. Hint 3

    if (!result.ok) { return "invalid: " + result.reason; } then return "port " + result.port;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export type PortResult = { ok: true; port: number } | { ok: false; reason: string };

export function parsePort(text: string): PortResult {
  const port = Number(text);
  if (!Number.isInteger(port)) return { ok: false, reason: "not a whole number" };
  if (port < 1 || port > 65535) return { ok: false, reason: "out of range" };
  return { ok: true, port };
}

export function describePort(text: string): string {
  const result = parsePort(text);
  if (!result.ok) {
    return "invalid: " + result.reason;
  }
  return "port " + result.port;
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export type PortResult = { ok: true; port: number } | { ok: false; reason: string };

export function parsePort(text: string): PortResult {
  const port = Number(text);
  if (!Number.isInteger(port)) return { ok: false, reason: "not a whole number" };
  if (port < 1 || port > 65535) return { ok: false, reason: "out of range" };
  return { ok: true, port };
}

export function describePort(text: string): string {
  const result = parsePort(text);
  return "port " + result.port;
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {describePort} from './main.ts';

test('a valid port is described', () => {
  assert.equal(describePort('8080'), 'port 8080', 'describePort("8080") should be "port 8080"');
});

test('text that is not a number gives its reason', () => {
  assert.equal(describePort('abc'), 'invalid: not a whole number', 'describePort("abc") should be "invalid: not a whole number"');
});

test('a number outside 1 to 65535 gives its reason', () => {
  assert.equal(describePort('70000'), 'invalid: out of range', 'describePort("70000") should be "invalid: out of range"');
  assert.equal(describePort('0'), 'invalid: out of range', 'describePort("0") should be "invalid: out of range"');
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Common mistakes

A misspelt case label

type Text = { type: "text"; body: string };
type Image = { type: "image"; url: string; width: number };
type Attachment = { type: "attachment"; name: string; bytes: number };
type Message = Text | Image | Attachment;

function icon(m: Message): string {
  switch (m.type) {
    case "text":
      return "T";
    case "imgae":
      return "I";
    default:
      return "F";
  }
}

console.log(icon({ type: "image", url: "/a.png", width: 10 }));

What tsc or Node.js prints

main.ts(10,10): error TS2678: Type '"imgae"' is not comparable to type '"attachment" | "image" | "text"'.

Why, and the fix

A case value must be one of the discriminant's literal types, and tsc lists the ones it accepts. Write case "image". Without tsc, the program would run and quietly print F for every image, because the misspelt case never matches.

Destructuring the discriminant with let

type Text = { type: "text"; body: string };
type Image = { type: "image"; url: string; width: number };
type Attachment = { type: "attachment"; name: string; bytes: number };
type Message = Text | Image | Attachment;

function link(m: Message): string {
  let { type } = m;
  if (type === "image") {
    return m.url;
  }
  return "";
}

console.log(link({ type: "image", url: "/a.png", width: 10 }));

What tsc or Node.js prints

main.ts(9,14): error TS2339: Property 'url' does not exist on type 'Message'.

Why, and the fix

tsc keeps the link between type and m only for a const (or a parameter that is never reassigned): a let could hold another value by the time of the check. Write const { type } = m;, or check m.type directly.

Destructuring data that only one member has

type Sent = { ok: true; id: number } | { ok: false; error: string };

function report({ ok, id }: Sent): string {
  return ok ? "sent #" + id : "failed";
}

console.log(report({ ok: true, id: 7 }));

What tsc or Node.js prints

main.ts(3,23): error TS2339: Property 'id' does not exist on type 'Sent'.

Why, and the fix

A destructuring pattern runs before any check, so it may only take properties that every member has. ok is on both members; id is not. Take the whole object, check ok, then read result.id: function report(result: Sent) { return result.ok ? "sent #" + result.id : "failed"; }.

TypeScript in the browser: the TypeScript 6.0.3 compiler, Apache-2.0, then your browser’s own engine. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Why optional properties are not enough

The Handbook's first Shape is one interface: kind: "circle" | "square", radius?: number and sideLength?: number. Inside if (shape.kind === "circle"), shape.radius is still number | undefined, and shape.radius ** 2 is error TS18048: 'shape.radius' is possibly 'undefined'. The type does not link kind to radius, so the check proves nothing. shape.radius! silences tsc, but nothing checks it: called with { kind: "circle" } it computes NaN. Instead, write one object type per case, each with a literal value in a common property and its own data required. That is a discriminated union, and the property, here kind, is its discriminant. Any name works: type, status, ok.

Narrowing on the discriminant

Without a check, you may read only properties every member has, such as m.type. === and !== on the discriminant narrow, and so do an early return and switch. Stacked cases, case "image": case "attachment":, give m: Image | Attachment, and default gets what is left. A case without return or break falls through, so the next case also sees the earlier member, and tsc rejects what that member lacks. A value not in the union is error TS2367 in a comparison and TS2678 in a case. Literal booleans work too: with { ok: true; id: number } | { ok: false; error: string }, if (result.ok) gives the first member and if (!result.ok) return leaves it.

Destructuring, and checks stored in constants

Since TypeScript 4.4, const { type } = m; if (type === "image") still narrows m, and so does const isImage = m.type === "image"; if (isImage). Since 4.6, const { kind, amount } = action narrows amount when you check kind. This works for a const, a readonly property, or a parameter the function never reassigns. With let, tsc drops the link, because the variable could change later: let { type } = m; if (type === "image") m.url is error TS2339: Property 'url' does not exist on type 'Message'. And you can only destructure what every member has: ({ ok, id }: Sent) is error TS2339 too, because the failed member has no id. Check first, then read.

Sources

Last reviewed October 4, 2026