Skip to content
Chapter 4Lesson 5

Composing types — unions and intersections

How TypeScript's union and intersection operators combine the types you already know into composed shapes.

The earlier lessons gave you types for a single value or shape: primitives, literal unions, named-field objects, tuples, and dynamic-keyed records. With those you can name the type of any one value, but not yet compose them into the larger shapes most code actually uses. These three lines do exactly that:

type FormattedAmount = string | number;
type UserOrMiss = User | null;
type AuthedRequest = BaseRequest & { token: string };

Each combines types you already know. FormattedAmount is a formatter parameter that accepts a raw number or a pre-formatted string. UserOrMiss is a lookup that returns a User row on a hit and null on a miss. AuthedRequest is a base request with an auth token added.

Both operators read in plain English. | means or: a value of A | B is one of the alternatives. On shape types, & means plus: a value of A & B has the fields of both. This lesson covers both operators, how to choose between them, and the bug each prevents, then introduces the discriminated union, the pattern the next chapter builds on.

| and & are set operations on types. Read a type as the set of values it admits: string is the set of all strings, and 'draft' is the set with exactly one member. The two operators combine those sets:

  • A | B is the union of inhabitants: a value of A | B is a value of A or a value of B, so the set of values grows.
  • A & B is the intersection of constraints: a value of A & B satisfies both A and B. Fewer values satisfy two constraints than one, so the set of values shrinks.
Union — A | B
A B
Every value in either circle.
The set of inhabitants grows.
Intersection — A & B
A B
Values in both circles at once.
On shape types, the set of fields grows.
Two operators, two set operations. The union shades both circles; the intersection shades only the overlap.

For primitive types, the picture maps directly onto the values. string | number admits every string and every number, a larger set than string alone. 'red' & 'blue' admits zero values, because no string is both 'red' and 'blue'.

For shape types, the same set picture applies to the values, but in daily use you read the fields instead. { id: string } & { email: string } has both id and email: a value that satisfies both constraints must carry every field either one names. So intersecting two shapes shrinks the value set while growing the field set, the part that trips beginners up.

A union value is one of the alternatives, not all of them. So the fields you can read without narrowing are exactly the ones every alternative shares. Four union shapes cover almost everything you’ll write, taken in order across the tabs below.

type Status = 'draft' | 'sent' | 'paid';
const next: Status = 'sent';

A literal union admits exactly the listed values: the three strings here, not every string. A finite, known domain calls for a literal union rather than the bare primitive. The typo 'pendng' is a compile error, because it isn’t a member of the union.

All four tabs apply one rule to different shapes:

On a union, you can read a field, or call a method, only if it exists on every variant. Anything else is a compile error, and the fix is to narrow the value before reading.

That’s the shape-union access rule .

Fixing a union access error: narrow, don’t widen or assert

Section titled “Fixing a union access error: narrow, don’t widen or assert”

One bug pattern follows directly from that rule. A function takes value: User | Guest and reads value.email, but Guest has no email, so the compiler errors. Three fixes suggest themselves; only the third works.

const greet = (value: { email?: string; name: string }): string => {
return `Hi, ${value.email ?? value.name}`;
};

Widening the type until the read compiles silences the compiler but drains the parameter of meaning. It no longer documents what the function accepts: a User, a Guest, and any other object with a name field all compile. The bug isn’t fixed, only hidden.

Never widen a shape union to read a non-shared field. Never assert past it. Narrow it instead.

Intersections on shapes: composing field sets

Section titled “Intersections on shapes: composing field sets”

Unions handle “one of several alternatives.” Intersections handle the other half: “a shape with fields from several sources.” On shape types, A & B carries every field either side names, because a value can satisfy both shape constraints only by holding all of those fields at once.

type WithId = { id: string };
type WithEmail = { email: string };
type IdAndEmail = WithId & WithEmail;
// equivalent to: { id: string; email: string }
const u: IdAndEmail = { id: 'usr_01', email: 'a@example.com' };

A value of IdAndEmail has id and email at once, so an object literal missing either field is a compile error at the assignment site.

Three production cases call for &.

Request payload composition. A base type captures the fields every request carries (requestId, timestamp), and each route’s payload composes that base with route-specific fields:

type BaseRequest = { requestId: string; timestamp: string };
type CreateInvoiceRequest = BaseRequest & {
customerId: string;
lines: InvoiceLine[];
};

The base type is reusable across every route, while the route-specific extension stays local to the file that ships it. The intersection joins them at the point of use, without a class hierarchy or a generic helper.

Extending a third-party type with project-local fields. When a library exports a type and you need that shape plus one or two of your own fields, write LibraryType & { localField: T }. This composes a new type only where you use it. Declaration merging via interface does a different job: it augments the library’s own type everywhere the library is used. When the extra fields are project-local and shouldn’t leak to every other consumer, reach for &.

Discriminated union variants. Each variant of a discriminated union is often a base shape composed with a discriminating extension. { status: 'success' } & { data: User } is the same type as { status: 'success'; data: User }, but the & form keeps the discriminant visually distinct from the payload when variants run long.

You’ve seen unions and intersections separately. One pattern combines them, and it’s the shape most TypeScript code uses to model a value that is in one of several states. It earns its place by replacing a worse design: a payload where every field is optional and a boolean flag tries to signal which fields are present.

type FetchResult<T> = {
isLoading: boolean;
data?: T;
error?: Error;
};
const render = (r: FetchResult<User>): string => {
if (r.isLoading) return 'Loading…';
return r.data!.name;
};

Three states (loading, success, error) collapsed into one record with optional fields and a boolean flag. The type system can’t tell that data is present when isLoading is false and error is absent, so every read needs an assertion like r.data!.name that the type system can’t verify. If the data layer ever returns a result where isLoading, data, and error don’t line up the way the renderer assumes, the read fails in production.

A discriminated union is a union of object types where each variant carries a literal-typed field that names which variant it is. That field is the discriminant , and narrowing on it separates the variants at compile time.

The next chapter builds this shape out into the patterns production code relies on, including the exhaustiveness check: once every variant is handled, the remaining branch has type never, so adding a variant later turns each unhandled site into a compile error.

Now write one yourself. The two @ts-expect-error directives check themselves: each fails the build unless the line it marks errors, so they prove at compile time that the union access rule blocks a read before narrowing.

Declare Result<T> as a discriminated union with ok as the literal discriminant — ok: true carries value: T, ok: false carries error: Error. The two @ts-expect-error directives must trigger (proving the shape-union access rule fires before narrowing), and the ^? query inside the if (r.ok) branch must resolve r.value to the inner record type.

  • Type query at line 19 must resolve to a type containing name: string
Booting type-checker…

A green grade means you authored the union, blocked the unnarrowed read, and watched the narrowed branch resolve r.value to the inner type — the three steps the next chapter turns into a production pattern.

Sort each scenario by whether the value is one of several shapes (|) or has the fields of both (&). One case is subtle.

Sort each composition by whether the value is one of several shapes (use `|`) or has the fields of both (use `&`). Drag each item into the bucket it belongs to, then press Check.

| — union of alternatives The value is one of several shapes
& — intersection of fields The shape has the fields of both
A function parameter that accepts a string or a number
A payload with the fields of a BaseRequest plus a token: string
A query result that may be a User or null
A Result<T> with ok: true; value: T and ok: false; error: Error variants
A third-party Session extended with a project-local tenantId: string field
A Status field that’s 'draft' | 'sent' | 'paid'

The subtle case is item 4, the discriminated Result<T>. The outer type is a union of two variants, so the answer is |. But each variant is internally a shape you could write as an intersection, since { ok: true } & { value: T } reads the same as { ok: true; value: T }. Both operators live in one type — exactly the pattern the next chapter builds on.

The choice in one move:

  • | for alternatives, when the value is one of several shapes: literal unions, mixed-primitive unions, shape unions, nullable unions, and the outer shape of a discriminated union.
  • & for field composition, when the shape has the fields of both: payload composition, third-party type extension, and the inside of a discriminated union’s variants.

Read every composed type as a set first. User | null is “either a User or null.” BaseRequest & { token: string } is “a value with every field BaseRequest declares, plus a token.” The operator is punctuation; the set reading is the language.