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.
How to read | and & as set operations
Section titled “How to read | and & as set operations”| 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 | 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. Fewer values satisfy two constraints than one, so the set of values shrinks.
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, 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.
Unions in practice: four shapes, one rule
Section titled “Unions in practice: four shapes, one rule”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.
const format = (amount: string | number): string => { return typeof amount === 'number' ? amount.toFixed(2) : amount;};.toFixed lives on number, not on string, so reading amount.toFixed(2) without the typeof check would error. The check narrows amount to number on the true branch and to string on the false branch.
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 lacks it. The fix is to narrow first, the trap the next section works through.
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 return type of User | null says the lookup may miss, so the caller can’t read .email until it narrows past null. A nullable union is still a union: same operator, same rule. The ?: field shorthand produces the same T | undefined shape, with the same narrowing.
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.
const greet = (value: User | Guest): string => { return `Hi, ${(value as User).email}`;};The as cast tells TypeScript “trust me, this is a User”, but changes nothing at runtime. The moment a real Guest flows through, value.email is undefined and the next read crashes. An assertion validates nothing; it only shifts what the compiler believes, so the bug survives to production. The next lesson covers the few cases where as is the right tool; this is not one of them.
const greet = (value: User | Guest): string => { if ('email' in value) { return `Hi, ${value.email}`; } return `Hi, ${value.name}`;};Narrow instead, with a check the language tracks. in is a real JavaScript operator, so 'email' in value runs at runtime, and TypeScript carries the result into each branch: inside the if, value is a User and value.email is readable; the fallthrough branch is a Guest. Both are safe at compile time and at runtime. The next lesson covers the full set of narrowing forms.
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.
Discriminated unions
Section titled “Discriminated unions”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.
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. Inside if (r.status === 'error'), r.error is typed as Error. On the fallthrough, r is the success variant and r.data is T. The runtime check on the discriminant drives the compile-time narrowing, and the ! from the previous tab disappears.
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.
Author a discriminated Result<T> shape
Section titled “Author a discriminated Result<T> shape”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
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.
Decide which operator to reach for
Section titled “Decide which operator to reach for”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.
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 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.
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.