Union Types and Narrowing · Lesson 9 of 17
Union Types and Type Narrowing
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);
#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));
text of length 2 number 3.1 yes nothing list of 2 API error 404 date 2026-11-07
| Check | Narrows |
|---|---|
typeof x === "string" | primitives: string, number, boolean, bigint, symbol, undefined, function |
x instanceof Date | class instances |
"prop" in x | objects 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 }));
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));
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)`));
[ '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
Write
toNumber(value: string | number | null): numberthat 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"));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.