Skip to content
aviral gupta

// B3.1 · ~30 min · Beginner

Union types and narrowing

After this lesson you can look at a function that takes a union type, say which line the compiler will reject and why, and make a switch that fails to compile when a new case is added.

Lesson 1 of 5 in B3 Narrowing

Start of the module

You will be able to

  • Narrow a union with typeof, in, instanceof and equality checks
  • Write a discriminated union with a kind field and an exhaustive switch using never
  • Explain why unknown forces a check where any switches checking off
  1. Warm-up · Activity 1 of 7

    Warm-up: a parameter is declared as id: number | string. What does that type say?

    function printId(id: number | string) {
      console.log("Your ID is: " + id);
    }
  2. Predict · Activity 2 of 7

    Predict before we explain. Compiled with strict mode on, what happens with this function?

    function size(value: string[] | null) {
      if (typeof value === "object") {
        return value.length;
      }
      return 0;
    }
  3. Practice · Activity 3 of 7

    Given these types, match each check to what TypeScript knows inside the if branch.

    type Fish = { swim: () => void };
    type Bird = { fly: () => void };
    interface Circle { kind: "circle"; radius: number }
    interface Square { kind: "square"; sideLength: number }
    type Shape = Circle | Square;
  4. Practice · Activity 4 of 7

    Which line does the compiler reject (strict mode)?

    function handle(a: any, b: unknown) {
      a.trim();                            // line 2
      const n: number = a;                 // line 3
      b.trim();                            // line 4
      if (typeof b === "string") b.trim(); // line 5
    }
  5. Practice · Activity 5 of 7

    Complete the exhaustiveness check. Fill in the type that makes this line fail to compile if a new Shape member is added but not handled.

    function getArea(shape: Shape): number {
      switch (shape.kind) {
        case "circle":
          return Math.PI * shape.radius ** 2;
        case "square":
          return shape.sideLength ** 2;
        default:
          const _exhaustiveCheck: ____ = shape;
          return _exhaustiveCheck;
      }
    }
    const _exhaustiveCheck: = shape;
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Inside the if, what type does TypeScript give x?

    function example(x: string | number, y: string | boolean) {
      if (x === y) {
        // what is the type of x here?
      }
    }
  7. Apply · Activity 7 of 7

    Mini-task. Model the outcome of an operation as a discriminated union Result with two members: { kind: "ok"; value: number } and { kind: "err"; message: string }. Write describeResult(r: Result): string with an exhaustive switch, and toResult(input: unknown): Result that narrows the input before using it. 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

Three kinds of narrowing in one program

show takes string | number | null and removes one member per check: === null first, then typeof. area switches on the kind property of a discriminated union, with a never check in the default branch. toCount takes unknown, as for JSON.parse results, and uses only what it has checked. Run npx tsc: no output means no errors. Then run node main.ts. Try adding | { kind: "triangle"; base: number } to Shape and run tsc again: the never line is reported.

main.ts

// Three kinds of narrowing on one page. tsc follows each check.
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // here shape is the circle member
    case "rect":
      return shape.width * shape.height; // here shape is the rect member
    default: {
      const unhandled: never = shape; // a new kind without a case fails here
      return unhandled;
    }
  }
}

function show(value: string | number | null): string {
  if (value === null) return "nothing"; // === null removes null
  if (typeof value === "number") return value.toFixed(1); // value is number
  return value.toUpperCase(); // only string is left
}

function toCount(input: unknown): number {
  if (typeof input === "number") return input;
  if (typeof input === "string" && input.trim() !== "") return Number(input);
  return 0; // anything else: null, objects, empty text
}

console.log(show(null), show(2.5), show("ok"));
console.log(area({ kind: "circle", radius: 1 }).toFixed(2));
console.log(area({ kind: "rect", width: 2, height: 3 }));
console.log(toCount(JSON.parse("4")), toCount(JSON.parse('"7"')), toCount(JSON.parse("null")));

Run it with

npx tsc
node main.ts

Output

nothing 2.5 OK
3.14
6
4 7 0
  • After return "nothing", null is gone, and after the typeof branch returns, only string is left for toUpperCase.
  • Inside case "circle" only radius exists; shape.width there would be a compile error.
  • JSON.parse("null") gives null, which neither typeof check accepts, so toCount returns 0 instead of crashing.
  • The types are gone at run time: the output is what the same JavaScript prints.
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

Narrow out null

formatAmount takes number | string | null. It already handles text, but tsc reports error TS18047: 'amount' is possibly 'null', and formatAmount(null) crashes. Make it return "no amount" for null, and keep the other results. Run the tests: the first one is the type-check.

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 diagnostic points at the last return: after the typeof branch, amount is still number | null there.

  2. Hint 2

    An equality check removes null: if (amount === null) { … } at the top of the function.

  3. Hint 3

    Do not use typeof amount === "object" for this: it works here only by accident, because typeof null is "object".

Show a solution

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

export function formatAmount(amount: number | string | null): string {
  if (amount === null) {
    return "no amount";
  }
  if (typeof amount === "string") {
    return Number(amount).toFixed(2) + " EUR";
  }
  return amount.toFixed(2) + " EUR";
}
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 function formatAmount(amount: number | string | null): string {
  if (typeof amount === "string") {
    return Number(amount).toFixed(2) + " EUR";
  }
  return amount.toFixed(2) + " EUR";
}

main.test.ts

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

test('formatAmount(4.5) is "4.50 EUR"', () => {
  assert.equal(formatAmount(4.5), '4.50 EUR', 'formatAmount(4.5) should be "4.50 EUR"');
});

test('formatAmount("12") is "12.00 EUR"', () => {
  assert.equal(formatAmount('12'), '12.00 EUR', 'formatAmount("12") should be "12.00 EUR"');
});

test('formatAmount(null) is "no amount"', () => {
  assert.equal(formatAmount(null), 'no amount', 'formatAmount(null) should be "no amount"');
});

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

Handle the new payment method

Payment gained a third member, { method: "invoice"; days: number }, and tsc now reports the never line in describePayment. Add the missing case so that an invoice for 30 days gives "invoice, due in 30 days". Keep the never check in the default branch.

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 diagnostic says which member reaches the default branch: { method: "invoice"; days: number; } is not assignable to never.

  2. Hint 2

    Add case "invoice": before default. Inside it, p is the invoice member, so p.days is a number.

  3. Hint 3

    return "invoice, due in " + p.days + " days";

Show a solution

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

export type Payment =
  | { method: "card"; last4: string }
  | { method: "paypal"; email: string }
  | { method: "invoice"; days: number };

export function describePayment(p: Payment): string {
  switch (p.method) {
    case "card":
      return "card ending " + p.last4;
    case "paypal":
      return "PayPal " + p.email;
    case "invoice":
      return "invoice, due in " + p.days + " days";
    default: {
      const unhandled: never = p;
      return unhandled;
    }
  }
}
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 Payment =
  | { method: "card"; last4: string }
  | { method: "paypal"; email: string }
  | { method: "invoice"; days: number };

export function describePayment(p: Payment): string {
  switch (p.method) {
    case "card":
      return "card ending " + p.last4;
    case "paypal":
      return "PayPal " + p.email;
    default: {
      const unhandled: never = p;
      return unhandled;
    }
  }
}

main.test.ts

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

test('a card payment names the last four digits', () => {
  assert.equal(describePayment({method: 'card', last4: '4242'}), 'card ending 4242', 'the card payment should give "card ending 4242"');
});

test('a PayPal payment names the email address', () => {
  assert.equal(describePayment({method: 'paypal', email: 'ada@example.com'}), 'PayPal ada@example.com', 'the PayPal payment should give "PayPal ada@example.com"');
});

test('an invoice names the days until it is due', () => {
  assert.equal(describePayment({method: 'invoice', days: 30}), 'invoice, due in 30 days', 'the invoice should give "invoice, due in 30 days"');
});

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

Checking typeof "object" to rule out null

function count(items: string[] | null): number {
  if (typeof items === "object") {
    return items.length;
  }
  return 0;
}

console.log(count(["a", "b"]));

What tsc or Node.js prints

main.ts(3,12): error TS18047: 'items' is possibly 'null'.

Why, and the fix

typeof null is "object" in JavaScript, so the check keeps both string[] and null, and tsc says so. Compare with null instead: if (items !== null), or items != null to remove undefined as well. A truthiness check, if (items), also works here, but it would also skip an empty string or 0 in other unions.

Reading a property that only one member has

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

function area(shape: Shape): number {
  return Math.PI * shape.radius ** 2;
}

console.log(area({ kind: "circle", radius: 1 }));

What tsc or Node.js prints

main.ts(6,26): error TS2339: Property 'radius' does not exist on type 'Shape'.

Why, and the fix

shape could be the square, which has no radius, and a union allows only what every member allows. Check the discriminant first: if (shape.kind === "circle") return Math.PI * shape.radius ** 2; then handle "square". A switch over shape.kind with a never check in default makes tsc remind you of every kind you have not handled.

Testing a property with a dot instead of in

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Animal = Fish | Bird;

function move(animal: Animal) {
  if (animal.swim) {
    animal.swim();
  }
}

move({ swim: () => console.log("swimming") });

What tsc or Node.js prints

main.ts(6,14): error TS2339: Property 'swim' does not exist on type 'Animal'.

Why, and the fix

In JavaScript, if (animal.swim) is a common way to test for a method, but TypeScript rejects reading swim before it knows animal is a Fish: Bird has no swim. The in operator is the check TypeScript understands: if ("swim" in animal) narrows animal to Fish, and animal.swim() compiles. In the else branch, animal is a Bird.

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

A union only allows what every member allows

A value of type string | number could be either, so TypeScript only allows an operation that is valid for every member of the union. To use a string method you first narrow: write a check, and tsc follows your control flow to give the value a more specific type in each branch. The Handbook calls such a check a type guard. typeof, ===, !== and != null, in and instanceof all narrow, as does an early return: after if (typeof x === "number") return …, x is a string for the rest of the function. Note: typeof null is "object", so typeof x === "object" does not rule out null. Later lessons take each check in turn.

Discriminated unions and never

Give every member of a union a common property with a literal type, such as kind: "circle" or kind: "square". Checking that property narrows the whole object to one member, so inside case "circle" you may read shape.radius. In a switch over kind, the default branch receives whatever is left; when every case is handled, that is never, the type with no values. Assigning the value to a variable of type never there turns “I forgot a case” into a compile error, such as Type 'Triangle' is not assignable to type 'never', the day someone adds a new member.

unknown at the boundary, narrowed before use

Module B2 showed the difference: any switches checking off, so every property access and call compiles; unknown also accepts every value, but allows nothing until you narrow it. Narrowing is what makes unknown usable. Inside if (typeof input === "number"), input is a number, and you may call input.toFixed(2). For data from outside your program, such as JSON.parse results or request bodies, give the value the type unknown and narrow it once, at the boundary. With any, a JSON string "3" flows into arithmetic unchecked, and "3" + 1 quietly becomes "31".

Sources

Last reviewed October 3, 2026