Union and Intersection Types: Combining, Narrowing, and Domain Modeling
Learn to model values with union types, combine object shapes with intersection types, narrow unions safely, and recognize when an intersection produces an impossible type.
Union and intersection types are TypeScript's two core ways to combine existing types into new ones — unions express "one of these," while intersections express "all of these at once."
Union types and narrowing
A union type, written with |, describes a value that can be any one of several types: type ID = string | number; means a value typed ID is either a string or a number, and the compiler only lets you use operations that are valid on every member of the union unless you first narrow it. This is why calling .toUpperCase() directly on an ID-typed value is an error — toUpperCase doesn't exist on number, so it isn't safe for the whole union.
Narrowing is the process of checking a union value at runtime in a way TypeScript recognizes, after which it treats the value as the smaller, more specific type within an if branch or similar. Common narrowing techniques include typeof checks (typeof id === "string"), instanceof checks for class instances, the in operator to check for a property's presence, and comparing a discriminant property's literal value (covered in depth in the Literal Types module).
Narrowing only lasts within the scope where TypeScript can prove the condition still holds — inside the if block, and any code path that returns or throws in the else branch, guaranteeing the narrowed type on the remaining code. Reassigning the variable, or narrowing inside a callback that could run later, can lose the narrowing, which is a common source of confusing "still shows the wide type" errors for newer TypeScript users.
Intersection types: combining object shapes
An intersection type, written with &, produces a new type that must satisfy every one of the combined types simultaneously: type Employee = Person & Developer; means a value typed as Employee must have every property Person requires and every property Developer requires, all at once, on a single object. This is the primary tool for composing several smaller, focused shapes into one larger shape without repeating fields via extends.
Unlike a union, where you only know a value is one of several possibilities until you narrow it, an intersection value is immediately fully-formed — there's no narrowing step, because by definition it already has everything. This makes intersections useful for mixing in cross-cutting concerns, such as combining a Timestamped shape ({ createdAt: Date; updatedAt: Date }) into many otherwise-unrelated record types.
Intersections apply to more than just object shapes — you can intersect function types, generic type parameters, or any other type — but combining two object shapes is by far the most common practical use, and is functionally very close to what interface X extends A, B {} achieves, with the earlier-noted difference in how conflicting property types are reported.
Impossible combinations and practical domain modeling
Intersecting two types with the same property name but incompatible primitive types produces never for that property, because no value can simultaneously be, say, both a string and a number — type Impossible = { id: string } & { id: number }; results in a type where id has type never, meaning no real value can ever satisfy the whole intersection. This is a common and confusing trap: the intersection itself doesn't error, but any attempt to actually construct a value of that type does, because you can't provide a valid id.
This differs from extends on interfaces with a conflicting property, which does raise an explicit compiler error at the point where the conflict is declared — one reason some developers prefer interface extension over intersection specifically for catching this class of mistake earlier and more clearly.
In practical domain modeling, unions and intersections are typically used together: unions represent the different states or variants a piece of data can be in (a LoadState that's Idle | Loading | Loaded | Errored), while intersections build up the properties common to a family of records (mixing an Auditable shape with { createdAt, updatedAt } into User, Order, and Invoice types alike). Recognizing which relationship you actually need — "is this an either/or, or an all-of-the-above" — is the core skill in choosing between them.
Union and intersection composition
ID is a union: a value is either a string or a number, never both at once. Employee is an intersection: a value must have every property from both Person and Developer simultaneously.
type ID = string | number;
interface Person {
name: string;
age: number;
}
interface Developer {
primaryLanguage: string;
yearsOfExperience: number;
}
type Employee = Person & Developer;
const e: Employee = {
name: "Ada",
age: 34,
primaryLanguage: "TypeScript",
yearsOfExperience: 8,
};Code Examples
The typeof id === "string" check narrows id from string | number down to string inside that branch, making .toUpperCase() valid; in the remaining code path (implicitly the else), TypeScript narrows id to number instead, making .toString() valid there. Expected result: E29B ID-000042 if (typeof id === "string") {
Employee requires every property from both Person and Developer at once. The dev object must supply all four properties, or TypeScript rejects it as incomplete — there's no partial or narrowed form for an intersection the way there is for a union. Expected result: Grace, 45, writes Kotlin (12y)
type ID = string | number;
function formatId(id: ID): string {
if (typeof id === "string") {
return id.toUpperCase();
}
return `ID-${id.toString().padStart(6, "0")}`;
}
console.log(formatId("e29b"));
console.log(formatId(42));interface Person {
name: string;
age: number;
}
interface Developer {
primaryLanguage: string;
yearsOfExperience: number;
}
type Employee = Person & Developer;
function introduce(e: Employee): string {
return `${e.name}, ${e.age}, writes ${e.primaryLanguage} (${e.yearsOfExperience}y)`;
}
const dev: Employee = {
name: "Grace",
age: 45,
primaryLanguage: "Kotlin",
yearsOfExperience: 12,
};
console.log(introduce(dev));Union vs. intersection
A union value is one shape at a time; an intersection value carries every shape's properties at once, merged into a single object.
UNION (Person | Developer) INTERSECTION (Person & Developer)
Person Developer Person
+-------+ +-----------+ +---------+
| name | | primaryLng| | name |
| age | | years Exp | +--------| age |--------+
+-------+ +-----------+ | +---------+ |
| |
a value is ONE of these ----> | Developer |
| +-------------+ |
+----| primaryLng |--------+
| yearsExp |
+-------------+
a value has ALL of these -> EmployeeImportant Rules
Use a union (|) when a value can genuinely be one of several distinct alternatives, and narrow it with typeof, instanceof, in, or a literal discriminant before using type-specific operations.
Use an intersection (&) when you're composing several smaller shapes into one object that must satisfy all of them simultaneously.
Watch for intersections of conflicting primitive property types (e.g. { id: string } & { id: number }) — the property collapses to never and no real value can satisfy the type, without the intersection declaration itself producing an error.
Narrowing is scope-limited: it holds only within the branch where the condition is proven true (or where the false branch returns/throws), and is lost across reassignment or asynchronous callbacks.
Where this shows up
Union types model UI and network states everywhere — loading/loaded/error states, HTTP status outcomes, form validation results — while intersection types are the standard way to mix cross-cutting shapes (timestamps, ownership metadata, permission flags) into many otherwise-distinct record types without duplicating those fields in every interface.
type Broken = { id: string } & { id: number }; // silently produces id: never type Combined = { id: string } & { role: string }; // intersect shapes with non-conflicting property names/types
Intersecting two object types that declare the same property name with incompatible primitive types doesn't error on the type declaration itself — it silently narrows that property to never, making the whole type impossible to construct a real value for, which usually only surfaces later as a confusing error where you build the object.
[multiple-choice] What does type Employee = Person & Developer; require of a value typed as Employee? A. It must be either a Person or a Developer, but not both B. It must have every property required by both Person and Developer simultaneously C. It must have none of the properties from either type D. It is a union that needs narrowing before use
B. It must have every property required by both Person and Developer simultaneously — Intersection (&) combines types so the result requires satisfying all of them at once — an Employee value needs every property from Person and every property from Developer on the same object.
[true-false] Intersecting { id: string } with { id: number } produces a compiler error on the type declaration itself. A. True B. False
B. False — The intersection declaration compiles fine, but the conflicting property collapses to type never, meaning no real object can ever satisfy it — the practical error only shows up later, when you try to construct a value of that impossible type.
[multiple-choice] Which technique correctly narrows a string | number union inside an if-branch? A. Casting with as string before any check B. Checking typeof value === "string" C. Adding a ? to the union D. Wrapping the union in an intersection with string
B. Checking typeof value === "string" — A typeof check is one of TypeScript's recognized narrowing techniques: inside the branch where typeof value === "string" is true, TypeScript treats value as string rather than the full string | number union.
Define type Vehicle = Car | Truck where Car has { kind: "car"; seats: number } and Truck has { kind: "truck"; payloadKg: number }. Write a function describe(v: Vehicle): string that narrows on kind to return a different message for each. Then define type Insured = Vehicle & { policyNumber: string } and construct one valid value of that type.
type Car = { kind: "car"; seats: number }; type Truck = { kind: "truck"; payloadKg: number }; type Vehicle = Car | Truck; function describe(v: Vehicle): string { if (v.kind === "car") { return Car with ${v.seats} seats; } return Truck carrying ${v.payloadKg}kg; } type Insured = Vehicle & { policyNumber: string }; const insuredCar: Insured = { kind: "car", seats: 4, policyNumber: "POL-99" };
Narrowing on the kind literal property works the same way as narrowing with typeof, just comparing a string literal instead.