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 the composed shapes real SaaS code ships every day.

The previous lessons gave you the vocabulary for a single value or shape: primitives, literal unions, named-field objects, tuples, dynamic-keyed records. With that vocabulary, you can name the type of any one value. What you can’t do yet is compose those shapes into the kinds of types real codebases ship every day. Three short scenarios show what that looks like in practice.

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

Each line composes shapes you already know. The first is a formatter parameter that accepts either a numeric amount or a pre-formatted string. The second is a query result that returns a User row when the lookup hits and null when it misses. The third is a request payload built from a base type with an auth token added. Each one takes a primitive, a User, a null value, or a BaseRequest, and combines them with one of two operators.

Both operators read in plain English. The | reads as or: a value of A | B is one of the alternatives. On shape types, the & reads as plus: a value of A & B has the fields of both shapes. This lesson teaches both operators, how to choose between them, and the bug each one prevents. It also introduces the discriminated union, the pattern the next chapter builds out, by showing both its shape and the bug it fixes.

One thing to expect ahead of time: composing a shape union is the easy part. The first compile error shows up when you try to read a field on the result, and the fix is not to widen the type or assert past it. The fix is to narrow the value first, which the next lesson covers in full. The section on the access rule below explains why that compile error is doing its job rather than getting in your way.

Start with the mental model. | and & are set operations on types. The trick is to 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. On shape types, that value has the fields of both. So the set of values shrinks, because fewer values satisfy two constraints than one, while the set of fields grows.
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, so its set is genuinely larger 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 tend to read the fields instead. { id: string } & { email: string } has both id and email, because a value that satisfies both shape constraints must carry every field either constraint names. Intersecting two shapes grows the field set, and that is the part that trips beginners. The reason is that the operator produces two facts at once. The value set shrinks, since fewer objects satisfy two shape constraints than one, and at the same time the field set grows, since the values that do satisfy both have to carry every named field.

Unions in practice: literals, primitives, shapes, and nullables

Section titled “Unions in practice: literals, primitives, shapes, and nullables”

A union value is one of the alternatives, not all of them. The set of values grows, but the set of fields you can read without narrowing is exactly the fields common to every alternative. Four union shapes cover everything you’ll write in 2026 SaaS code, and the tabs below take them in order.

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

A literal union admits exactly the listed values: the three strings here, not every string. This follows the rule from the lesson on primitives, literals, and the four corners: a finite known domain calls for a literal union rather than the primitive. The typo 'pendng' is a compile error, because that value isn’t a member of the union.

The pattern across all four tabs is the same rule applied 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 . Everything that follows in this chapter and the next rests on it.

The shape-union trap: narrow, don’t widen

Section titled “The shape-union trap: narrow, don’t widen”

The rule above explains why one specific bug pattern keeps surfacing. A function takes value: User | Guest and reads value.email, but Guest doesn’t have email, so the compiler errors. At this point it’s easy to reach for the wrong fix. Three responses suggest themselves, and only one of them is right.

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

The first beginner impulse is to silence the compiler by widening the type until the read compiles. The error goes away, but the type stops carrying information. The parameter no longer documents what the function accepts: a call site that hands in a User and one that hands in a Guest both compile, and so does any other object with a name field. The bug isn’t fixed, only hidden.

The rule, stated directly:

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 of composition: “a shape with fields from multiple sources.” This is where the inversion from earlier matters. You read & as and, so A & B is “satisfies A and B.” That’s correct, but on shape types the everyday use is field composition: the resulting type carries every field either constraint named. That follows from the reading, because a value can only satisfy both shape constraints by carrying 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 the same time. An object literal that’s missing either field is a compile error at the assignment site. This is the same completeness check as Record<LiteralUnion, V> from the previous lesson, applied to fields instead of keys.

Three production cases call for & in 2026 SaaS code.

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 in the codebase, while the route-specific extension stays local to the file that ships it. The intersection composes them where the type is used, without forcing 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 the same shape plus one or two project-local fields, LibraryType & { localField: T } is the right tool. There is a separate tool for a different job: declaration merging via interface, named in the lesson on object shapes and modifiers, augments the library’s own type everywhere the library is used. The two jobs differ in scope. & composes a new type at the point where you use it, while declaration merging modifies the original declaration module-wide. When the extension is project-local and shouldn’t leak into every other consumer of the library type, 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 equivalent to { status: 'success'; data: User }: the inline form and the & form are interchangeable. Writing the & form keeps the discriminant visually distinct from the payload, which helps when the variants are long. The next section introduces the pattern in full.

You’ve seen unions and intersections separately. One pattern combines them: a union of object types where every 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. This is the discriminated union, the shape most production TypeScript code uses to model “this value is in one of N states.”

The pattern 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;
};

This is the shape a beginner reaches for: 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 guaranteed present when isLoading is false and error is absent. Every field is optional, so every read needs an assertion, like r.data!.name. That ! asserts something 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.

The pattern in one sentence:

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.

Three forward links are worth naming so you know where this lands:

  • Narrowing on the discriminant. The if (r.status === 'loading') check is equality narrowing, one of the five narrowing forms the next lesson covers. This lesson shows it in passing; the next names every form and walks the three legitimate triggers for as and !.
  • The exhaustiveness check. When every variant of a discriminated union has been handled, the type of the remaining branch is never. Code in that branch becomes a way to prove at compile time that nothing was missed. Add a new variant to the union later, and every unhandled site turns into a compile error pointing at the missing case. The full pattern lives in the next chapter.
  • The full range of discriminated unions. Fetch results, form states, command and event payloads, and Result<T, E> types for error handling. The next chapter takes the shape you saw above and builds out the patterns most production TypeScript code uses to keep state legible. This lesson sets up the shape so the next chapter has language to reference.

Now write one yourself. The exercise asks you to declare a Result<T> type as a discriminated union with two variants, success carrying a value and failure carrying an error, and to confirm the type system catches a read that skips narrowing. The @ts-expect-error directives check themselves: each one fails the build if the line it marks doesn’t error. That makes them a compile-time check that the union access rule is doing its job.

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…

When the exercise grades green, you’ve done three things. You wrote a discriminated union. You confirmed the type system blocks the unnarrowed read on .value and .error. And you watched the narrowed branch resolve r.value to the inner type. Authoring the union, blocking the unsafe read, and narrowing to a safe one are the three steps the next chapter turns into a production pattern.

Here are six composition scenarios for the two operators. Sort each one by whether the value is one of several shapes (|) or has the fields of both (&). Most are straightforward; one is subtle, and that subtle case is the bridge to the next chapter.

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 sort answer is |. But each variant is internally a shape that could be written as an intersection, since { ok: true } & { value: T } reads the same as { ok: true; value: T }. Both operators appear in one type, and that is exactly the bridge to the next chapter, where discriminated unions become the central pattern.

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 that has every field BaseRequest declares plus a token.” The operator is the punctuation, and the set-theoretic reading is the language.