Union and Intersection Types in TypeScript: Modeling Real-World Data
Most real data isnโt one fixed shape โ an API response is either a success payload or an error payload, a form field is either filled in or empty, a value coming off a queue might be one of five different event types. Union types (|) model โthis OR that.โ Intersection types (&) model โthis AND that, combined into one.โ Together theyโre how TypeScript represents data that plain, single-shape interfaces canโt โ and the discriminated union pattern built from them is arguably the single most useful idiom in the entire type system.
Union Types โ โOne of Theseโ
let id: string | number;id = "abc123"; // fineid = 42; // fineid = true; // Error: Type 'boolean' is not assignable to type 'string | number'function formatId(id: string | number): string { if (typeof id === "string") { return id.toUpperCase(); // TypeScript knows id is a string here } return id.toString(); // and knows it's a number here โ narrowed automatically}A union type says โthis value is one of these listed types, and you must prove which one before doing anything type-specific with itโ โ you canโt call .toUpperCase() on a string | number directly, because a number doesnโt have that method. This is where narrowing (its own dedicated guide) becomes essential: typeof id === "string" inside the if block tells the compiler that within that branch, id really is a string, and every operation only valid on strings becomes safe again.
Only Operations Valid on Every Member Are Allowed
interface Bird { fly(): void; layEggs(): void;}
interface Fish { swim(): void; layEggs(): void;}
function getPet(): Bird | Fish { return Math.random() > 0.5 ? getBird() : getFish();}
const pet = getPet();pet.layEggs(); // fine โ both Bird and Fish have this methodpet.fly(); // Error: Property 'fly' does not exist on type 'Fish'This is the rule that trips people up first: a union type only exposes the properties/methods present on every member of the union, not the ones present on some of them โ because at runtime, you genuinely donโt know which one you have until you narrow it. pet.fly() might work if pet happens to be a Bird, but it would throw at runtime if pet is actually a Fish, so TypeScript refuses to allow the call at all until you narrow with something like if ("fly" in pet).
Discriminated Unions โ The Pattern That Makes Unions Practical
interface LoadingState { status: "loading";}interface SuccessState { status: "success"; data: string[];}interface ErrorState { status: "error"; message: string;}
type FetchState = LoadingState | SuccessState | ErrorState;
function render(state: FetchState) { switch (state.status) { case "loading": return "Loading..."; case "success": return `Loaded ${state.data.length} items`; // state narrowed to SuccessState โ .data exists case "error": return `Error: ${state.message}`; // state narrowed to ErrorState โ .message exists }}Every member of FetchState shares one property (status) with a distinct literal type value (โloadingโ, โsuccessโ, โerrorโ) โ this shared, uniquely-valued property is called the discriminant. TypeScriptโs control-flow analysis is smart enough to narrow the entire union down to exactly one member the moment you check the discriminant, which is why state.data is accessible inside the "success" case without any additional casting or checking.
This pattern replaces a class of runtime bug thatโs genuinely common without it: representing state with a loose shape like { status: string; data?: string[]; message?: string } means every consumer has to remember, by convention, which fields are valid for which status โ nothing stops you from reading .message when status is "success". A discriminated union makes that mistake a compile error instead of a silent undefined at runtime.
Exhaustiveness checking, revisited
function render(state: FetchState): string { switch (state.status) { case "loading": return "Loading..."; case "success": return `Loaded ${state.data.length} items`; // forgot the "error" case! default: const check: never = state; // Error: Type 'ErrorState' is not assignable to type 'never' throw new Error("Unhandled state"); }}If a new discriminant value gets added to the union later (a "cancelled" state, say) and a switch somewhere forgets to handle it, this never assignment pattern (introduced in the basic types guide) turns the missed case into a compile error at every place it matters, instead of a silent fallthrough at runtime.
Intersection Types โ โBoth of These, Combinedโ
interface Named { name: string;}interface Aged { age: number;}
type Person = Named & Aged;
const alice: Person = { name: "Alice", age: 30 }; // must satisfy BOTH shapesWhere a union says โone of these,โ an intersection says โall of these, merged into a single type that has every property from every member.โ Person requires both name and age โ itโs not optional to have just one.
The mixin pattern
interface Timestamped { createdAt: Date; updatedAt: Date;}
interface Serializable { serialize(): string;}
interface Loggable { log(): void;}
type FullEntity = Timestamped & Serializable & Loggable & { id: string; name: string };
function createEntity(name: string): FullEntity { return { id: crypto.randomUUID(), name, createdAt: new Date(), updatedAt: new Date(), serialize() { return JSON.stringify(this); }, log() { console.log(`Entity: ${this.name}`); }, };}This โcombine several small, focused shapes into one composite typeโ pattern mirrors mixins in JavaScript โ instead of one large interface with every capability baked in (which the interfaces guide already flagged as a design smell), you compose several narrow ones with & exactly where a specific combination is actually needed.
Intersections of incompatible primitive types produce never
type Impossible = string & number; // never โ no value can simultaneously be both a string and a number
function process(value: Impossible) { // this function can never actually be called with a valid argument}This is a real, if rare, gotcha: intersecting two primitive types that share no possible values collapses to never, silently โ TypeScript doesnโt error on the type declaration itself, only later, wherever you try to actually produce or use a value of that type. This is the same underlying mechanism discussed in the type aliases vs interfaces guide for conflicting intersected properties.
Combining Unions and Intersections
type Role = "admin" | "editor" | "viewer";
interface BaseUser { id: string; name: string;}
type AdminUser = BaseUser & { role: "admin"; permissions: string[] };type RegularUser = BaseUser & { role: "editor" | "viewer" };
type AppUser = AdminUser | RegularUser;
function canDelete(user: AppUser): boolean { return user.role === "admin"; // narrows to AdminUser inside this check, .permissions becomes accessible}Real domain modeling almost always combines both operators โ a base shape shared by everything (via intersection), specialized with a discriminant that varies by variant (via union). This is the standard shape of a well-modeled domain type in a TypeScript codebase handling anything with multiple related-but-distinct entities: users with different roles, orders in different states, events of different kinds.
Common Pitfalls
| Symptom | Likely cause |
|---|---|
| โProperty does not existโ on a value youโre sure has it | Youโre accessing a property only present on some union members without narrowing first |
A discriminated unionโs switch compiles even though a case is missing | The default branch isnโt asserting never โ add the exhaustiveness check shown above |
An intersection type shows properties as never | Two intersected members declare the same property key with incompatible types |
| Union narrowing โresetsโ after calling a function | TypeScript canโt always track narrowing across function calls that might mutate the variable โ reassign to a new const inside the narrowed branch if this happens |
| A union with 15+ members becomes hard to maintain | Consider whether some variants share a discriminant hierarchy, or whether the data belongs in a lookup structure instead of a type-level union |
Common Real-World Patterns
Modeling an API response envelope
type ApiResponse<T> = | { success: true; data: T } | { success: false; error: { code: string; message: string } };
async function fetchUser(id: string): Promise<ApiResponse<{ id: string; name: string }>> { const res = await fetch(`/api/users/${id}`); if (!res.ok) { return { success: false, error: { code: String(res.status), message: res.statusText } }; } return { success: true, data: await res.json() };}
const result = await fetchUser("123");if (result.success) { console.log(result.data.name); // narrowed โ .data exists here, .error doesn't} else { console.log(result.error.message);}Adding capabilities to an existing type without modifying it
type WithId<T> = T & { id: string };
interface Draft { title: string; body: string;}
type SavedDraft = WithId<Draft>; // { title, body, id } โ Draft plus an id, without touching Draft's definitionNarrowing a union parameter to a specific literal
type Theme = "light" | "dark" | "system";
function applyTheme(theme: Theme) { const resolved: "light" | "dark" = theme === "system" ? getSystemPreference() : theme; document.documentElement.setAttribute("data-theme", resolved);}Frequently Asked Questions
Why canโt I access a property that exists on only some members of a union? Because at runtime you have no guarantee which member you actually have until you narrow โ accessing a property that might not exist on the actual runtime value would be unsafe, and TypeScriptโs whole purpose is refusing operations it canโt prove are safe.
Whatโs the difference between a discriminated union and just using optional properties on one big interface? A discriminated union makes invalid combinations (like a "loading" state that also has .data populated) impossible to construct in the first place โ the type system enforces it. One big interface with optional fields only documents which combinations are intended; nothing stops incorrect combinations from being created and passed around.
Can an intersection type ever make a shape impossible to satisfy? Yes โ intersecting incompatible primitive types (string & number) or object types with conflicting property types for the same key both collapse to never, meaning no real value can ever satisfy that type. This usually indicates a modeling mistake rather than an intentional design.
Is there a performance cost to using large unions or intersections? Not at runtime โ like every TypeScript construct, both are fully erased at compile time. At compile time, extremely large or deeply nested unions/intersections can measurably slow down type-checking in very large codebases, but this is rarely a practical concern until a union genuinely has dozens of members or the intersection nests many levels deep.
How many members can a union realistically have before it becomes unwieldy? Thereโs no hard limit, but past a handful of variants, consider whether some naturally group under a shared parent discriminant, or whether a lookup table/map keyed by discriminant value would be clearer than a long switch statement over the whole union.
Should I use a string literal union or an enum for a fixed set of options? For most modern TypeScript code, a literal union ("pending" | "shipped" | "delivered") is the simpler default โ zero runtime footprint, works naturally with plain JS objects and JSON, and integrates directly with discriminated unions the way this guide has shown. The enums guide covers the specific cases (reverse lookups, a namespaced grouping of related constants) where an enum is genuinely the better fit.
Whatโs Next
Unions and intersections give you the vocabulary for โthis or thatโ and โthis and thatโ โ the two operators discriminated unions are built from, and one of the most genuinely useful patterns in TypeScript. Next: Type Narrowing and Type Guards, where we go deeper into exactly how TypeScript proves which member of a union youโre holding at any given point in your code.