Two operators combine types. A union, A | B, is a value that is one of these.
An intersection, A & B, is a value that is all of these at once. Unions
you will use constantly; intersections rather less, and knowing why is half of this lesson.
Unions
let id: string | number;
id = 'abc'; // fine
id = 42; // fine
id = true; // errorThe pipe reads as "or". You have already met the most important special case — a union of literal types, which is how the pizza app models every fixed set of values it has:
export type ProductType = 'PIZZA' | 'DRINK';
export type SizeName = 'SMALL' | 'MEDIUM' | 'LARGE';
export type ToppingCategory = 'MEAT' | 'VEGGIE' | 'CHEESE';
export type OrderType = 'DELIVERY' | 'CARRYOUT';export type OrderStatus =
| 'PENDING_PAYMENT'
| 'PAID'
| 'PREPARING'
| 'COMPLETED'
| 'CANCELLED';The leading | on the first member is optional and purely cosmetic — it lets every
line look the same, which makes the diff clean when a status is added.
What you get for that is real. SizeName is not a comment saying which strings are
allowed; it is enforced at every assignment, every function call and every object literal in the
codebase. Misspell 'MEDUIM' and the build stops.
You can only reach what they have in common
The rule that governs everything about unions: until you narrow, you may only use members that exist on every branch.
// This does not compile.
function pad(id: string | number) {
return id.padStart(8, '0');
// ~~~~~~~~~
// Property 'padStart' does not exist on type 'string | number'.
}padStart exists on string and not on number, so it is not
available on the union. This is not TypeScript being awkward — the value really might be a number,
and calling padStart on it really would throw.
What is allowed is anything both have:
function describe(id: string | number) {
return id.toString(); // fine, both have toString
}To get at the rest, you prove which branch you are on. That is narrowing, and it is the next lesson, because unions are only half a tool without it.
Assignability runs one way
A member is assignable to the union. The union is not assignable to a member.
let size: SizeName = 'LARGE'; // fine
let anySize: SizeName | null = size; // fine — widening
// This does not compile.
let back: SizeName = anySize;
// ~~~~
// Type 'SizeName | null' is not assignable to type 'SizeName'.Obvious when stated, and the source of most union errors you will actually see — usually as
string failing to be assignable to a literal union, which is the widening problem from
lesson 3.
Unions of objects
The members do not have to be primitives. When they are object types, you get the most useful pattern in the language:
export type Outcome = { ok: true } | { ok: false; failure: ApiFailure };That is from the Angular admin store, and it says something a single interface cannot: the
failure field exists if and only if ok is false.
Written as one shape with an optional field —
interface Outcome { ok: boolean; failure?: ApiFailure }— you would have four possible combinations instead of two, including the nonsense ones: a success carrying a failure, and a failure carrying nothing. The union makes those unrepresentable.
Because ok has a literal type in each branch, checking it tells the compiler which
branch you are in. That is a discriminated union, and
lesson 10 shows what it buys you.
A worked example: modelling a screen's state
The best argument for unions is what they let you stop writing. Here is the shape almost every data-loading component starts as:
interface State {
loading: boolean;
error: string | null;
data: Order[] | null;
}Three independent fields is eight combinations, and only three of them are real. The type
permits { loading: true, error: 'Boom', data: [...] } — loading, and failed, and
holding results — which is not a state your code should ever have to render.
So every component that uses it develops the same defensive ladder, and they do not all get it in the same order:
if (state.loading) return <Spinner />;
if (state.error) return <Alert>{state.error}</Alert>;
if (!state.data) return null; // can this happen? nobody is sure
return <OrderTable orders={state.data} />;That third line is the tell. It exists because the type cannot say "if we are not loading and there is no error, there is definitely data", so every reader has to guess.
A union says it:
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: Order[] };Four states, exactly four, and each carries precisely the data that state has. There is no
success without data and no error without a message, because those combinations cannot be
constructed. The data field does not exist to be null-checked; in the only branch
where you can reach it, it is there.
This is the same move the Angular app's Outcome makes, and it is worth reaching for
whenever you notice a group of fields whose validity depends on each other.
Optional properties are unions too
A small thing that ties earlier lessons together. This:
interface OrderCreateRequest {
guestEmail?: string;
}gives guestEmail the type string | undefined when you read it. The
? is a union with extra rules about whether the key must be present — see
lesson 6 — but the value you get out is an
ordinary union, and everything in this lesson applies to it.
That is why address.label.toUpperCase() fails: toUpperCase is not
available on every member of string | undefined, exactly as padStart was
not available on string | number. One rule, not two.
Intersections
An intersection demands everything from both sides:
interface Timestamped { createdAt: string; updatedAt: string }
interface Identified { id: UUID }
type Entity = Timestamped & Identified;
const e: Entity = {
id: 'abc',
createdAt: '2026-09-01T09:00:00',
updatedAt: '2026-09-01T09:00:00',
}; // all three requiredNote the direction, which reads backwards at first. | gives you fewer
guaranteed members than either side; & gives you more. A union of two
object types is the smaller thing to work with, even though "or" sounds bigger.
Conflicting properties give you never
The intersection of two incompatible types is not an error at the declaration. It is
never:
type Impossible = string & number; // never — no value is bothWith objects it happens per property, which is worse because the type still looks usable:
type A = { price: number };
type B = { price: string };
type C = A & B; // price: never
// This does not compile.
const c: C = { price: 9.99 };
// ~~~~
// Type 'number' is not assignable to type 'never'.Nothing complained when C was declared. The error arrives at every attempt to
create one, pointing at the value rather than the mistake. This is the trap
lesson 7 mentioned —
interface extends catches the same conflict at the declaration.
Where intersections are actually worth it
Three cases come up often enough to name.
Adding to someone else's props. A component that wraps a native element and adds its own options:
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant: 'primary' | 'danger';
loading?: boolean;
};You could not write that with interface extends as conveniently, and it is the
single most common intersection in React code.
Combining derived types. When both halves come from utility types, an intersection is the only thing that joins them:
type ProductDraft = Omit<Product, 'id'> & { tempId: string };Branding. The trick from lesson 3, which is an intersection with a type that has no values:
type OrderId = string & { readonly __brand: 'OrderId' };Outside those, if you are describing a domain object, prefer interface extends. You
get the conflict check and better error messages for free.
Unions are sets, and behave like them
Three consequences of that, all of which save confusion later.
Order is irrelevant. These are the same type, and TypeScript may print it back to you in either order:
type A = 'PAID' | 'CANCELLED';
type B = 'CANCELLED' | 'PAID'; // identical to ADuplicates collapse. 'PAID' | 'PAID' is just
'PAID', and string | 'PAID' is just string — because the
literal is already a member of string, adding it changes nothing. That last one catches
people out: a union with a wide member in it is only as narrow as its widest member.
never disappears, as
lesson 4 covered. The empty set contributes
nothing.
One more that is occasionally useful to know: boolean is itself a union,
true | false. That is why a conditional type over a boolean distributes
into two branches, and why Exclude<boolean, false> gives you
true.
Unions distribute; intersections do not
One behaviour worth knowing before it surprises you. Some type operations applied to a union are applied to each member separately and the results re-joined:
type Boxed<T> = T extends unknown ? T[] : never;
type A = Boxed<string | number>; // string[] | number[]
// NOT (string | number)[]That is distribution, and it is why Exclude and Extract work.
It only happens for conditional types over a naked type parameter, which is a mouthful covered
properly in lesson 15. Mentioned
here so that when a union unexpectedly turns into a union of arrays, you know the word to search
for.
One practical upshot of duplicates collapsing: adding a member to a union is a safe change for
producers and a breaking change for consumers. Anything that creates an
OrderStatus still compiles; anything that switches over one now has a case
it does not handle. Which is precisely why the exhaustiveness check in the next lesson is worth the
three lines it costs.
A rule of thumb
Reach for a union whenever a value has a small, known set of possibilities — a status, a size, a mode, a result that either worked or did not. That is most of the modelling you will do, and it is the thing TypeScript does better than most type systems.
Reach for an intersection when you are genuinely bolting two independent sets of properties
together and neither owns the other. If one of them is clearly the base, extends says
so more clearly.
Next
Enums, and What to Use Instead — why the pizza app has
108 TypeScript files and not one enum in any of them.