Technology  /  TypeScript

๐Ÿ”ท TypeScript 16 guides ยท updated 2026

Type erasure, structural typing, generics, and the type-level programming toolkit โ€” TypeScript taught the way it actually behaves at compile time and at runtime.

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"; // fine
id = 42; // fine
id = 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 method
pet.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.

status === 'loading'

status === 'success'

status === 'error'

FetchState

(LoadingState | SuccessState | ErrorState)

Narrowed to LoadingState

Narrowed to SuccessState

(.data now accessible)

Narrowed to ErrorState

(.message now accessible)

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 shapes

Where 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

SymptomLikely cause
โ€Property does not existโ€ on a value youโ€™re sure has itYouโ€™re accessing a property only present on some union members without narrowing first
A discriminated unionโ€™s switch compiles even though a case is missingThe default branch isnโ€™t asserting never โ€” add the exhaustiveness check shown above
An intersection type shows properties as neverTwo intersected members declare the same property key with incompatible types
Union narrowing โ€œresetsโ€ after calling a functionTypeScript 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 maintainConsider 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 definition

Narrowing 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.