LearnFrenzy
Sign in Take a free test

Union Types and Narrowing · Lesson 9 of 17

Union Types and Type Narrowing

4 min read Updated Nov 8, 2026 Saurabh Samir

Real data is often "one of several things": an ID that's a number or a string, a value that may be null, an API result that's either data or an error. A union type (A | B) models that. To use a union safely, you narrow it – check which member you have – and TypeScript tracks the result through your if statements.

In this lesson

  • Union types and what you can do with them
  • Narrowing with typeof, instanceof, in and equality
  • Truthiness narrowing and null checks
  • Custom type guards with is

Union types

type Id = number | string;

function formatId(id: Id): string {
  // id.toUpperCase();          // error: toUpperCase doesn't exist on number
  return typeof id === "number" ? `#${id.toString().padStart(5, "0")}` : id.toUpperCase();
}

console.log(formatId(42), formatId("ord-777"));

let selected: string | null = null;           // "no selection yet"
selected = "Pune";
console.log(selected.length);

Output

#00042 ORD-777
4

On a union you can only use what every member supports. To use something specific to one member, narrow first.

Narrowing techniques

class ApiError extends Error {
  constructor(public status: number, message: string) { super(message); }
}

function describe(value: string | number | boolean | Date | ApiError | null | string[]): string {
  if (value === null) return "nothing";                                  // equality
  if (typeof value === "string") return `text of length ${value.length}`;  // typeof
  if (typeof value === "number") return `number ${value.toFixed(1)}`;
  if (typeof value === "boolean") return value ? "yes" : "no";
  if (Array.isArray(value)) return `list of ${value.length}`;            // Array.isArray
  if (value instanceof ApiError) return `API error ${value.status}`;     // instanceof (before Date)
  return `date ${value.toISOString().slice(0, 10)}`;                     // only Date is left
}

const inputs = ["hi", 3.14159, true, null, ["a", "b"], new ApiError(404, "Not Found"), new Date("2026-11-07")];
for (const v of inputs) console.log(describe(v));

Output

text of length 2
number 3.1
yes
nothing
list of 2
API error 404
date 2026-11-07
CheckNarrows
typeof x === "string"primitives: string, number, boolean, bigint, symbol, undefined, function
x instanceof Dateclass instances
"prop" in xobjects that have a property
x === null, x === "admin"specific values
Array.isArray(x)arrays
if (x)removes null, undefined (and other falsy values)

The in operator

interface Admin { name: string; permissions: string[] }
interface Customer { name: string; orders: number }

function summary(user: Admin | Customer): string {
  if ("permissions" in user) {                  // user: Admin here
    return `${user.name} can ${user.permissions.join(", ")}`;
  }
  return `${user.name} has ${user.orders} orders`;   // user: Customer here
}

console.log(summary({ name: "Asha", permissions: ["edit", "publish"] }));
console.log(summary({ name: "Ravi", orders: 3 }));

Output

Asha can edit, publish
Ravi has 3 orders

Truthiness and null checks

function initials(name: string | undefined | null): string {
  if (!name) return "?";                        // removes undefined, null and ""
  return name.split(" ").map((w) => w[0]?.toUpperCase() ?? "").join("");
}
console.log(initials("asha verma"), initials(undefined), initials(""));

function discount(percent: number | undefined): number {
  return percent ?? 10;                         // ?? keeps 0; a truthiness check wouldn't
}
console.log(discount(25), discount(0), discount(undefined));

Output

AV ? ?
25 0 10

Truthiness can hide valid values

if (count) also rejects 0, and if (name) rejects "". When 0 or an empty string are valid, check explicitly: count !== undefined or use ??.

Custom type guards

interface Book { kind: string; title: string; pages: number }

function isBook(value: unknown): value is Book {          // "value is Book" is the type predicate
  return (
    typeof value === "object" && value !== null &&
    "title" in value && typeof value.title === "string" &&
    "pages" in value && typeof value.pages === "number"
  );
}

const data: unknown[] = [
  { kind: "book", title: "Clean Code", pages: 464 },
  { title: "Untitled" },
  "random text",
];
const books = data.filter(isBook);                          // Book[] thanks to the guard
console.log(books.map((b) => `${b.title} (${b.pages} pages)`));

Output

[ 'Clean Code (464 pages)' ]

A function returning value is Book tells TypeScript that when it returns true, the argument is a Book. Type guards are how you safely turn unknown data – JSON, form input, messages – into typed values. TypeScript 5.5+ can even infer simple guards from filter callbacks.

Practice

Try it yourself

  1. Write toNumber(value: string | number | null): number that returns 0 for null, parses strings and passes numbers through.

    Show solution
    function toNumber(value: string | number | null): number {
      if (value === null) return 0;
      if (typeof value === "string") {
        const n = Number(value);
        return Number.isNaN(n) ? 0 : n;
      }
      return value;
    }
    console.log(toNumber("42"), toNumber(7), toNumber(null), toNumber("abc"));

    Output

    42 7 0 0

Quick check

What can you do with a value of type string | number without narrowing?

Only what both types support (e.g. toString()).

Which check narrows class instances?

instanceof.

What does value is User mean as a return type?

It's a type predicate: when the function returns true, value is a User.

Summary

  • A | B means "either"; only shared members are usable until you narrow.
  • Narrow with typeof, instanceof, in, equality, Array.isArray and truthiness (carefully).
  • Custom type guards (x is T) turn unknown data into typed values.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links