A | B for variants, A & B for composition
Modeling TypeScript types purely with any or loosely shaped objects gives away the strength of the type system. Union types describe one of several possible shapes, intersection types combine multiple shapes into one. Used correctly, they produce API models and compositions that the compiler actually enforces.
Table of Contents
- 1. Why any and loose typing fall short
- 2. Union types: fundamentals and narrowing
- 3. Discriminated unions and exhaustiveness checks
- 4. Intersection types: fundamentals and object merging
- 5. Practice: modeling API response variants
- 6. Practice: mixin-like composition with intersection types
- 7. Common mistakes with union and intersection types
- 8. Union and intersection types with generics and utility types
- 9. Union vs. intersection vs. interface extension compared
- 10. Summary
- 11. FAQ
1. Why any and loose typing fall short
The easiest way out of an unclear data model is any or an object type with nothing but optional fields. Both work in the short term because the compiler lets almost everything through, but that is exactly the problem: errors that should have surfaced at compile time only show up in the browser or in front of a customer. A response object with ten optional fields does not describe which fields actually occur together, only that each one could theoretically be missing on its own. The type system becomes documentation without any enforcement power.
Union types and intersection types solve this problem in two different ways. A union type A | B says: the value is either shape A or shape B, never some arbitrary mix of both. An intersection type A & B says: the value satisfies all the requirements of A and B at the same time. Used deliberately, both constructs let you model real states, variants, and capabilities so that invalid combinations simply fail to compile, instead of only surfacing as an undefined error at runtime.
2. Union types: fundamentals and narrowing
The syntax A | B describes a type whose value is either A or B. Classic use cases are status values like "idle" | "loading" | "success" | "error", IDs that may be either a number or a string, or return values of functions that produce different object shapes depending on the input. As long as TypeScript does not know which specific variant is present, only the properties shared by all variants are accessible. That is by design: the compiler prevents access to fields that simply do not exist in some variants.
To regain access to variant-specific fields, the type has to be narrowed, a process known as type narrowing. The most common tools for this are typeof for primitive types, instanceof for class instances, and the in operator to check whether a property exists on an object. Inside the respective branch, TypeScript automatically knows the narrowed type and allows access to the variant-specific fields, without any manual type assertion.
// Union type: id can be either a number or a string
type EntityId = number | string;
function formatId(id: EntityId): string {
// typeof narrows the union to the specific branch
if (typeof id === "number") {
return `#${id.toString().padStart(6, "0")}`;
}
return id.toUpperCase();
}
// Union of object shapes narrowed with the "in" operator
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
if ("radius" in shape) {
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2;
}
// Remaining branch is narrowed to Square
return shape.side ** 2;
}
class ApiError extends Error {}
class ValidationError extends Error {}
function handleError(error: ApiError | ValidationError): void {
if (error instanceof ApiError) {
console.error("API failure:", error.message);
return;
}
console.warn("Validation failed:", error.message);
}
3. Discriminated unions and exhaustiveness checks
A discriminated union is a union type where every variant carries a shared literal field, usually called kind or type, whose value is unique per variant. That single field is enough for TypeScript to determine the entire object shape inside a switch or if block, without checking several fields at once. The pattern is far more robust than loose unions built from purely optional fields, because every variant carries exactly the fields that belong to it, no more and no less.
The second essential building block is the exhaustiveness check using the never type. Adding a function to the default branch of a switch that expects a parameter of type never, and passing it the not-yet-handled remainder type, makes the compiler raise an error as soon as a new variant is added to the union but forgotten in the switch. That turns a potentially silent runtime issue into an immediately visible compile error, long before the code is even tested.
type PaymentMethod =
| { kind: "creditCard"; cardNumber: string; expiry: string }
| { kind: "paypal"; email: string }
| { kind: "invoice"; billingAddress: string };
function describePayment(method: PaymentMethod): string {
switch (method.kind) {
case "creditCard":
return `Card ending in ${method.cardNumber.slice(-4)}`;
case "paypal":
return `PayPal account ${method.email}`;
case "invoice":
return `Invoice to ${method.billingAddress}`;
default:
// Exhaustiveness check: fails to compile if a variant is missing
return assertNever(method);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled payment method: ${JSON.stringify(value)}`);
}
4. Intersection types: fundamentals and object merging
The syntax A & B produces a type whose values must satisfy all requirements of A and B at the same time. For two object types, that means the resulting object must have every property from A and every property from B. This is especially useful for combining recurring base shapes, such as Timestamped with createdAt and updatedAt, with specific domain types like Product or Order, without repeating the base shape manually in every single type.
It is important to understand that intersection types behave additively for object types, but destructively for primitive types. { a: string } & { b: number } produces an object with both fields, but string & number produces never, because no value can be both a string and a number at once. If two object types overlap on a property with different types, say { id: string } and { id: number }, the overlapping property itself becomes an intersection of the two field types, here also never, and the field can no longer be meaningfully assigned a value.
5. Practice: modeling API response variants
A classic use case for discriminated unions is modeling asynchronous loading states: a request is either not yet started, currently loading, succeeded, or failed. Instead of an object with the optional fields data, error, and isLoading, where all three could theoretically be set at once, a union with four clear variants describes exactly the states that can actually occur in the UI. The big payoff shows up during rendering: a component can switch on status and knows with certainty, in every branch, which fields are available.
The same technique pays off at the API response level. A generic ApiResult<T> type with success and error variants forces every caller to handle both cases before accessing data. That prevents the common bug class where a response object gets treated as successful by accident even though the backend returned an error status, because data could still appear present in a loosely typed model.
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function renderProductState(state: RequestState<Product>): string {
switch (state.status) {
case "idle":
return "Waiting for request";
case "loading":
return "Loading product...";
case "success":
// data is only accessible in this branch
return `Loaded: ${state.data.name}`;
case "error":
return `Failed to load: ${state.error}`;
}
}
interface Product {
id: string;
name: string;
price: number;
}
// Generic API result wraps every backend call in the same shape
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } };
async function fetchProduct(id: string): Promise<ApiResult<Product>> {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
return { ok: false, error: { code: response.status, message: response.statusText } };
}
return { ok: true, data: await response.json() as Product };
}
6. Practice: mixin-like composition with intersection types
While union types describe variants, intersection types shine when composing capabilities. A typical example: an entity object needs to be identifiable, timestamped, and versionable all at once. Instead of merging these three aspects into one large interface, you define three small, focused types and combine them with & only at the point of use. This mirrors the composition-over-inheritance principle known from object-oriented programming, just entirely at the type level with no runtime cost.
This technique works just as well with higher-order functions that extend an object with additional methods, a pattern very close to the classic JavaScript mixin. Such a function's return type signature uses & to tell the caller that the returned object carries both the original and the newly added properties. That keeps the type of the composed object fully traceable, without requiring an explicit shared base class.
// Small, focused capability types instead of one large interface
interface Identifiable {
id: string;
}
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Versioned {
version: number;
}
// Composed at the point of use via intersection
type Entity = Identifiable & Timestamped & Versioned;
const order: Entity & { total: number } = {
id: "ord-1029",
createdAt: new Date(),
updatedAt: new Date(),
version: 3,
total: 249.9,
};
// Mixin-style function that composes capabilities at runtime and type level
function withLogging<T extends object>(target: T): T & { log: (msg: string) => void } {
return {
...target,
log(msg: string) {
console.log(`[entity] ${msg}`);
},
};
}
const loggableOrder = withLogging(order);
loggableOrder.log("order created"); // available thanks to the intersection type
7. Common mistakes with union and intersection types
The most common mistake is a loose union without a shared discriminant field, such as { data?: T; error?: string; isLoading?: boolean } instead of a proper discriminated union. Types like this can be declared just fine, but TypeScript cannot infer which field combinations are actually valid. The result is code that has to manually check for undefined everywhere, even though the type system could automate those checks if the structure had been modeled as a discriminated union from the start.
A second common mistake is assuming that intersection types automatically favor one of the two types on conflict. string & number produces never, not string. Accidentally intersecting two incompatible primitive types, usually through a misresolved generic constraint, produces a type that can no longer be assigned any real value, and the error frequently surfaces in a completely different part of the code, wherever the resulting type is actually used.
A third pitfall involves excess property checks on intersections of multiple object types with overlapping but slightly different optional fields. TypeScript checks object literals more strictly than variables, so a literal assigned directly to an intersection sometimes produces different errors than a previously declared variable of the same type. Not knowing this difference leads to confusion about why the same value throws an error in one place but not in another.
8. Union and intersection types with generics and utility types
Generics and unions complement each other especially well in combination with the built-in utility types Extract and Exclude. Extract<T, U> filters a union T down to the variants matching a condition U, while Exclude<T, U> does the exact opposite. On a discriminated union, this lets you pull out a single variant, for example to write a helper function that only handles the success case of an ApiResult, without rewriting the entire union type again.
NonNullable<T> is at its core also just a specialized application of Exclude that removes null and undefined from a union. Generic functions that work with both union and intersection constraints, such as function merge<A, B>(a: A, b: B): A & B, return exactly the combined type to the caller without requiring a manual type assertion. That significantly reduces the need for as casts, because the compiler correctly infers the combination itself.
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } };
// Extract pulls out exactly the success variant of the union
type SuccessResult<T> = Extract<ApiResult<T>, { ok: true }>;
function unwrap<T>(result: SuccessResult<T>): T {
return result.data;
}
// Exclude removes a variant, useful for narrowing down handled states
type Status = "idle" | "loading" | "success" | "error";
type ActiveStatus = Exclude<Status, "idle">;
// Generic merge helper returns the precise intersection type
function merge<A extends object, B extends object>(a: A, b: B): A & B {
return { ...a, ...b };
}
const withDefaults = merge({ theme: "dark" }, { locale: "en-US" });
// withDefaults: { theme: string } & { locale: string }
9. Union vs. intersection vs. interface extension compared
In practice, the question keeps coming up whether a structure should be modeled with a union, an intersection, or classic interface inheritance. The table summarizes the typical scenarios and the more robust solution for each.
| Scenario | Risky approach | Recommended pattern | Benefit |
|---|---|---|---|
| Modeling multiple states | Object with nothing but optional fields | Discriminated union with a "kind" field | Only valid field combinations are possible |
| Switching over variants | Unchecked default branch | Exhaustiveness check with never | Missing a variant breaks the build |
| Combining primitives | Expecting string & number | Use the union string | number instead | Avoids an accidental never |
| Combining capabilities | One large interface with every field | Compose small types with & | Reusable, clearly separated |
| Stable, extensible hierarchy | Forcing inheritance via intersection | Use interface extends instead | Better error messages, declaration merging |
As a rule of thumb: whenever a data model can be split into clearly distinct, mutually exclusive variants, a discriminated union is the right choice. Whenever an object needs several independent capabilities or property groups at once, an intersection is the right fit. For stable, public contract types with a clear hierarchy and the option to extend later via declare module, interface extends often remains the more maintainable choice over an intersection of multiple interfaces.
Mironsoft
TypeScript tooling, frontend architecture, and type-safe headless integrations
Ready for a type-safe frontend architecture on your Magento stack?
We model data flows, API clients, and build scripts with clean union and intersection types, so errors surface at compile time instead of in front of your customer at checkout.
Type modeling review
Auditing existing types for loose unions and risky intersections
API client typing
Discriminated unions for headless and REST/GraphQL integrations
Build tooling
Strict tsconfig settings and exhaustiveness checks in CI
10. Summary
Union types and intersection types solve two different modeling problems. Union types describe a value taking one of several clearly distinct shapes, most robustly as a discriminated union with a shared literal field and an exhaustiveness check via never. Intersection types combine several shapes into one, ideal for composing small, focused capabilities instead of one large interface. Both constructs consistently move errors that would otherwise only surface at runtime into compile time.
The biggest lever is consistently replacing loose object types built purely from optional fields with real discriminated unions, and using intersection types for composition rather than inheritance hierarchies. Utility types such as Extract, Exclude, and NonNullable complement both constructs without forcing you to manually duplicate union types. Applying these patterns consistently produces a type system that catches real errors instead of merely simulating documentation.
Union and Intersection Types - The Essentials at a Glance
Union types (A | B)
Describe one of several possible shapes. Most robust as a discriminated union with a shared "kind" field.
Intersection types (A & B)
Combine several shapes into one. Ideal for composing small capabilities, not for inheritance hierarchies.
Exhaustiveness check
A never-typed function in the default branch fails the build as soon as a variant is forgotten.
Common pitfalls
Loose unions without a discriminant, string & number resulting in never, overlapping fields with different types.