Union types
A union type says a value is "one of these types". You have already used them — string | null,
"pending" | "paid", string | number — but this module treats them as the powerful idea they are.
Unions plus narrowing (the next lessons) are what make TypeScript genuinely expressive rather than
merely a spell-checker, so it is worth slowing down here.
The idea
A union is written with | between types, and means "a value of any one of these":
let id: string | number;
id = "abc"; // fine
id = 42; // fine
id = true; // error: Type 'boolean' is not assignable to type 'string | number'
string | number is the type of a value that is either a string or a number — but not a boolean,
and not both at once. Unions appear everywhere:
type Status = "pending" | "paid" | "shipped"; // a union of literal types
type Nullable = string | null; // a value or null
type Id = string | number; // two primitive options
type Result = { ok: true; data: string } | { ok: false; error: string }; // two object shapes
That last one — a union of two object shapes — is the most powerful form, and the discriminated-unions
lesson builds an entire pattern on it. For now, note the range: unions combine literals, primitives,
null, and whole object types.
The restriction: you can only use what every member shares
Here is the crucial rule, and the reason narrowing exists. When a value is a union, TypeScript only lets you do things that are valid for every member of the union:
function describe(id: string | number) {
id.toString(); // fine — both string and number have toString
id.toUpperCase(); // error: 'toUpperCase' does not exist on type 'number'
id.toFixed(2); // error: 'toFixed' does not exist on type 'string'
}
id might be a string or a number. toString() works because both have it. But toUpperCase() is a
string method that a number lacks, and toFixed() is a number method a string lacks — so TypeScript
forbids each, because it cannot guarantee the value is the right one. A union only exposes the common
capabilities of its members, which is exactly the safe thing to do: you cannot call a string method
on something that might be a number.
This restriction is not a limitation to fight — it is the safety. And the way you lift it, to access type-specific behaviour, is narrowing.
Narrowing: proving which member you have
To use string-specific or number-specific behaviour, you first prove which type the value is, with an ordinary check — and inside that check, TypeScript narrows the type:
function describe(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase(); // here TypeScript KNOWS id is a string — toUpperCase is allowed
}
return id.toFixed(2); // here TypeScript knows id is a number — toFixed is allowed
}
Inside if (typeof id === "string"), TypeScript narrows id from string | number down to just
string, so all string methods are available. After the if (in the return below), it knows id
must be the other member, number, so number methods are available. You use a normal JavaScript
check, and TypeScript follows your reasoning, narrowing the type as you rule out possibilities. The
next lesson is all the ways to narrow; this one plants the pattern: a union restricts you to the
common capabilities until you narrow to a specific member.
Unions versus any — precision, not surrender
It is worth contrasting a union with any, because beginners sometimes use any where a union is
right:
function process(value: any) { ... } // gives up all safety
function process(value: string | number) { ... } // precise — exactly two possibilities, both checked
any says "I do not care what this is" and turns off checking. A union says "this is exactly one of
these specific types" and keeps full checking — you just have to narrow before using type-specific
features. When you find yourself reaching for any because "it could be a few different things", a
union is almost always what you actually want: it captures the real possibilities and keeps the
compiler helping you.
Unions of objects — a taste
The most powerful unions combine object shapes, modelling "this is one of a few kinds of thing":
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // narrowed to the circle — radius available
}
return shape.width * shape.height; // narrowed to the rectangle — width/height available
}
Shape is "a circle or a rectangle", each with its own properties. The kind property tells you
which one you have, and checking it narrows the type so the right properties become available. This is
the discriminated union — the single best pattern in TypeScript, and the subject of its own lesson.
For now, register the shape of it: a union of object types, each tagged with a common literal property,
narrowed by checking that property.
Check your work
What a union type is. A value that is one of several types — string | number is either a
string or a number (not both, not a boolean).
What forms a union can combine. Literals, primitives, null, and whole object shapes.
The key restriction. A union only exposes what every member shares; type-specific methods are forbidden until you narrow.
Why that restriction is safety. You cannot call a string method on something that might be a number.
What narrowing does. A normal check (like typeof x === "string") proves which member you have, and
TypeScript narrows the type inside that branch so type-specific behaviour becomes available.
Union versus any. A union captures the exact possibilities and keeps checking; any surrenders
checking entirely — prefer a union when a value could be a few specific things.
The most powerful union. A union of object shapes tagged with a common literal property — the discriminated union.
Practice
- Declare
let id: string | number. Assign a string, a number, and a boolean; confirm which is rejected. - In a function taking
string | number, calltoString()(works),toUpperCase()andtoFixed()(both error). Explain why. - Narrow with
if (typeof id === "string")and calltoUpperCase()inside; confirm it now works, and thatidis anumberafter the branch. - Replace an
anyparameter that "could be a string or a number" with astring | numberunion and note the added safety. - Write a
Shapeunion of a circle and a rectangle and anareafunction that narrows onkind. - Try to access
shape.radiusbefore narrowing and read the error. Then narrow and confirm it works. - Write a union of three literal statuses and a function that behaves differently for each.
Official documentation
- TypeScript — Union Types — The
|operator and its rules. - TypeScript — Narrowing — The overview the next lessons detail.
- TypeScript — Working with union types — The common-capabilities restriction.
Next: narrowing, in all its forms.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship