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.
How to read | and &: values and fields
Section titled “How to read | and &: values and fields”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 | Bis the union of inhabitants. A value ofA | Bis a value ofAor a value ofB, so the set of values grows.A & Bis the intersection of constraints. A value ofA & Bsatisfies bothAandB. 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.
A | B The set of inhabitants grows.
A & B On shape types, the set of fields grows.
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.
const format = (amount: string | number): string => { return typeof amount === 'number' ? amount.toFixed(2) : amount;};The read-without-narrowing rule applies here too. .toFixed lives on number, not on string, so calling amount.toFixed(2) directly, without the typeof check, would error. The typeof check inside the conditional narrows amount to number on the true branch and to string on the false branch. That’s a preview of the next lesson, which covers every narrowing form.
type User = { id: string; email: string; name: string };type Guest = { id: string; name: string };
const greet = (value: User | Guest): string => { return `Hi, ${value.name}`; // return value.email; // error: email is on User, not Guest};value.name reads cleanly because name lives on both variants. value.email errors because Guest doesn’t have it. This is the shape-union trap the next section works through. The fix isn’t to widen the type or assert past it; it’s to narrow first.
const getUser = (id: string): User | null => { // ...};
const u = getUser('usr_01');// u.email // error: u may be nullif (u !== null) console.log(u.email); // ok after narrowingA function returning User | null says explicitly that the lookup may miss, so the caller can’t read .email without narrowing past the null first. A nullable union is still a union: same operator, same rule. The ?: field shorthand from the lesson on object shapes and modifiers types the field as T | undefined, and the same narrowing applies.
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.
const greet = (value: User | Guest): string => { return `Hi, ${(value as User).email}`;};The second beginner impulse is to override the compiler with as. The cast tells TypeScript “trust me, this is a User”, but the runtime value is unchanged. The moment a real Guest flows through, value.email is undefined and the next line that reads it crashes. A type assertion doesn’t validate anything at runtime; it only changes what the compiler believes, so the bug stays hidden until production. The next lesson covers the three legitimate triggers for as, and this isn’t one of them.
const greet = (value: User | Guest): string => { if ('email' in value) { return `Hi, ${value.email}`; } return `Hi, ${value.name}`;};The right call is to narrow with a runtime check the language tracks. The 'email' in value check runs at runtime, since in is a real JavaScript operator, and TypeScript carries the narrowing into the if branch: inside the block, value is typed as User, so value.email is readable. The fallthrough branch types value as Guest. Both branches are safe at compile time and at runtime. The next lesson covers the full set of narrowing forms (typeof, in, instanceof, Array.isArray, discriminant equality, custom predicates). For now, the direction is what matters.
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.
Discriminated unions, seeded
Section titled “Discriminated unions, seeded”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.
type FetchResult<T> = | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error };
const render = (r: FetchResult<User>): string => { if (r.status === 'loading') return 'Loading…'; if (r.status === 'error') return `Error: ${r.error.message}`; return r.data.name;};Each variant carries a literal-typed status field. Inside if (r.status === 'loading'), TypeScript narrows r to the loading variant: no data, no error, just the discriminant. Inside if (r.status === 'error'), r.error is typed as Error, present and readable. On the fallthrough, r is the success variant and r.data is T, with no assertion needed. The discriminant carries information from the runtime check into the compile-time narrowing, and the ! from the previous tab disappears entirely.
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 forasand!. - 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.
Author a discriminated Result<T> shape
Section titled “Author a discriminated Result<T> shape”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
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.
Decide which operator to reach for
Section titled “Decide which operator to reach for”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.
string or a numberBaseRequest plus a token: stringUser or nullResult<T> with ok: true; value: T and ok: false; error: Error variantsSession extended with a project-local tenantId: string fieldStatus 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.
External resources
Section titled “External resources”The official treatment of the | operator. Terse, accurate, and the canonical citation for the syntax.
The official treatment of & on object types, including the field-composition reading this lesson uses.
Matt Pocock's walk through the flag-boolean vs. discriminated-union contrast, the same one this lesson sets up for the next chapter.
The deep dive on TypeScript types as sets, including identity and idempotent laws, extending the set-theoretic mental model this lesson uses.