Real programs are full of values that refuse to sit still. An input field might hold a number or be left empty. A function might hand you back a result, or it might hand you back an error. A setting could be the word "auto" or an actual pixel count. In the real world, a single slot often holds more than one kind of thing.
TypeScript has two small but powerful tools for describing exactly this kind of flexibility: union types and intersection types. They sound technical, but the ideas behind them are everyday-simple. One says “this can be either of these.” The other says “this must be all of these at once.” Once these click, a huge amount of TypeScript stops feeling mysterious. Let’s walk through both, slowly.
What a union type actually means
A union type describes a value that can be one of several types. You write it with the pipe symbol |, which you can read out loud as the word “or.”
let id: string | number;
id = "abc-123"; // fine, it's a string
id = 42; // also fine, it's a number
id = true; // Error: boolean is not allowed
That single line, string | number, says: “the value in id is allowed to be a string, or a number — and nothing else.” TypeScript will happily accept either one, and it will firmly reject anything outside that list.
Think of it like a parking spot reserved for “cars or motorbikes.” Both are welcome. A truck is not. The sign isn’t vague; it lists exactly what fits.
You are not limited to two options. A union can have as many members as you need:
type Status = "loading" | "success" | "error";
let pageState: Status = "loading";
pageState = "error"; // fine
pageState = "done"; // Error: not one of the allowed values
Notice something interesting here. The members of this union aren’t even types like string or number — they’re specific string values. TypeScript lets you build a type out of exact values, and a union of them gives you a clean, self-documenting list of the only allowed options. This pattern shows up constantly.
Why unions are everywhere
Once you start looking, you’ll see unions all over real code. They tend to appear whenever a value has more than one legitimate shape.
A common one is “a value, or nothing yet”:
type User = {
name: string;
email: string;
};
let currentUser: User | null = null;
// later, after someone logs in:
currentUser = { name: "Jane Doe", email: "jane@example.com" };
Here User | null means the variable holds a real user object or the value null (a deliberate “nothing here”). That’s honest. Before login there is no user, so the type says so out loud, and TypeScript will remind you to handle the empty case.
Another classic is the success-or-error result:
type SuccessResult = {
ok: true;
data: string;
};
type ErrorResult = {
ok: false;
message: string;
};
type FetchResult = SuccessResult | ErrorResult;
A FetchResult is either a success (with data) or an error (with message), never an awkward mix of both. This mirrors how the real operation behaves: it works, or it fails. The type follows reality instead of fighting it.
Read the pipe as 'or'
Whenever you see A | B, mentally say “A or B.” A string | number is “a string or a number.” A User | null is “a user or nothing.” That little habit makes even long unions easy to read at a glance.
The catch: a union only gives you the shared parts
Unions are great, but there’s a rule you have to respect. When a value could be one of several types, TypeScript only lets you use the things that are guaranteed to exist for every member of the union. Anything specific to just one member is off-limits until you prove which one you’re holding.
function printId(id: string | number) {
// id.toUpperCase(); // Error: numbers don't have toUpperCase
console.log(id); // fine — every type can be printed
}
toUpperCase() is a string method. But id might be a number, and numbers don’t have it. So TypeScript blocks the call to keep you safe. This isn’t TypeScript being difficult — it’s protecting you from a crash that would happen at runtime the moment someone passed in a number.
This is the natural tension of unions. They give you flexibility about what a value can be, and in exchange they ask you to be careful about what you do with it.
Narrowing: safely using a union
The way out of that restriction is called narrowing. Narrowing means writing a small check that proves, in a particular branch of code, exactly which member of the union you’re dealing with. Once TypeScript is convinced, it relaxes and lets you use that type’s specific features.
The simplest tool for this is the typeof check:
function printId(id: string | number) {
if (typeof id === "string") {
// In here, TypeScript knows id is a string
console.log(id.toUpperCase());
} else {
// And in here, it must be a number
console.log(id.toFixed(2));
}
}
Inside the if branch, you’ve proven id is a string, so toUpperCase() is allowed. Inside the else, the only remaining possibility is a number, so toFixed() is allowed. You’ve split one flexible value into two confident branches.
id: string | number
│
typeof id === "string"?
┌────┴────┐
yes no
│ │
id is string id is number
.toUpperCase() .toFixed(2)
There are several ways to narrow — typeof, checking for a specific property, comparing against a known value — and they each deserve a closer look. For now, the key idea to carry forward is this: a union gives you flexibility, and narrowing gives that flexibility back its safety. They are two halves of the same tool.
Narrowing is a whole topic of its own
We’re only touching narrowing lightly here so the union concept makes sense. The different techniques — and the way TypeScript tracks your checks branch by branch — are worth studying on their own once unions feel comfortable.
Intersection types: combining instead of choosing
If a union is “either of these,” an intersection is the opposite idea: “all of these, combined into one.” You write it with the ampersand &, which you can read as the word “and.”
Intersections are most useful with object types. They let you take two separate shapes and merge them into a single shape that has every property from both.
type Person = {
name: string;
age: number;
};
type Employee = {
employeeId: string;
department: string;
};
type StaffMember = Person & Employee;
A StaffMember is a Person and an Employee at the same time. That means an actual value must satisfy both — it needs every property from each:
const dev: StaffMember = {
name: "John Doe",
age: 29,
employeeId: "ACY-0481",
department: "Engineering",
};
Leave out any one of those four properties and TypeScript complains, because a StaffMember is the complete combination, not a pick-one. This is handy when you’re building up complex objects from small, reusable pieces. You describe each piece once, then stitch them together with &.
A frequent real pattern is layering extra fields onto a base shape:
type BaseRecord = {
id: string;
createdAt: string;
};
type Article = BaseRecord & {
title: string;
body: string;
};
Every Article automatically carries id and createdAt from BaseRecord, plus its own title and body. You wrote the common fields once and reused them — no copy-paste.
Union vs intersection, side by side
It’s easy to mix up which symbol does what, so here’s the contrast in one place.
| Aspect | Union A | B |
Intersection A & B |
|---|---|---|
| Read it as | “A or B” | “A and B” |
| A value must be | one of the types | all of the types at once |
| Properties you can use | only the shared ones (until you narrow) | every property from both |
| Typical use | a value with several possible shapes | merging object shapes together |
| Everyday phrase | “string or number” | “person and employee” |
A small mental trick: a union of objects gives you fewer guaranteed properties (only what they share), while an intersection gives you more (everything from each side). That feels backwards at first — “or gives less, and gives more?” — but it makes sense once you remember that a union member only has to satisfy one side, so you can only count on the overlap.
Don't intersect conflicting primitives
Intersection shines with object types. If you try string & number, you get a type that nothing can ever satisfy (a value can’t be a string and a number at the same time). TypeScript may not error immediately, but no real value will fit. Reach for & to combine object shapes, not to mix unrelated primitives.
A quick combined example
These two tools often work together. You can have a union where each member is itself an intersection, or fields that are unions inside an intersected shape. Here’s a compact taste:
type ApiResponse =
| { status: "ok"; data: string }
| { status: "fail"; error: string };
function handle(res: ApiResponse) {
if (res.status === "ok") {
console.log(res.data); // narrowed to the success shape
} else {
console.log(res.error); // narrowed to the error shape
}
}
The status field acts as a label that tells the two union members apart. Checking it narrows the value cleanly into one branch or the other — the same narrowing idea from before, now driving a real decision. This combination of “a union of labelled shapes plus a check on the label” is one of the most useful patterns in all of TypeScript.
Recap
Union and intersection types are how TypeScript describes values that don’t fit a single neat box.
- A union (
A | B) means a value is one of several types — “string or number,” “user or nothing,” “success or error.” Unions show up everywhere because real values often have more than one valid shape. - With a union, you can only use the parts shared by every member until you narrow — a small check, like
typeof, that proves which type you’re holding so TypeScript lets you use its specific features safely. - An intersection (
A & B) means a value is all of several types at once, combining their properties. It’s the go-to tool for merging object shapes out of small, reusable pieces. - Read
|as “or” and&as “and,” remember that a union gives you the shared parts while an intersection gives you the combined parts, and you’ll have the core of TypeScript’s type system in your pocket.
Get comfortable with these two, and the next natural step is narrowing in depth — the set of techniques that turn a flexible union into safe, confident code.