Master TypeScript union and intersection types — how | and & work, discriminated unions for safe branching, structural composition with intersections, and the real semantic difference between OR-typed and AND-typed shapes.
A union type (A | B) is a type that holds exactly one of several options at any given moment — like a box that contains either a spanner or a screwdriver, but you don't know which until you look inside. An intersection type (A & B) holds all of the options simultaneously — like a Swiss Army knife that has both a blade AND a screwdriver AND scissors all at once. When you narrow a union, you figure out which one is in the box. When you use an intersection, you expect every tool to be present immediately.
A union type means a value can be one of the listed types. TypeScript only allows you to access properties that are common to all members — you must narrow before accessing member-specific properties:
type StringOrNumber = string | number;
function double(value: StringOrNumber): StringOrNumber {
// Only properties that exist on BOTH string AND number are accessible
// value.toString() — OK, both have toString()
// value.toUpperCase() — Error! number has no toUpperCase()
// value.toFixed() — Error! string has no toFixed()
if (typeof value === "string") {
return value.repeat(2); // Narrowed to string — string methods available
}
return value * 2; // Narrowed to number — arithmetic available
}
// Literal unions — a very common pattern for enumerating options
type Direction = "north" | "south" | "east" | "west";
type Status = "idle" | "loading" | "success" | "error";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
Add a shared literal property to every union member to create a discriminated union. TypeScript can then narrow the entire shape based on that one property's value:
// Each member has a unique 'type' literal — this is the discriminant
type Action =
| { type: "INCREMENT"; amount: number }
| { type: "DECREMENT"; amount: number }
| { type: "RESET" }
| { type: "SET_USER"; user: User };
function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "INCREMENT":
// action: { type: "INCREMENT"; amount: number }
return { ...state, count: state.count + action.amount };
case "RESET":
// action: { type: "RESET" } — no amount property accessible
return initialState;
case "SET_USER":
// action: { type: "SET_USER"; user: User }
return { ...state, user: action.user };
default:
return state;
}
}
// Discriminated unions also work with if/else — the discriminant can be any literal
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; width: number; height: number };
function area(s: Shape): number {
if (s.kind === "circle") return Math.PI * s.radius ** 2;
if (s.kind === "square") return s.side ** 2;
return s.width * s.height; // TypeScript knows kind === "rect" here
}
An intersection type means a value must simultaneously satisfy all of the listed types. The result type has all properties from all members:
interface HasId { id: number; }
interface HasName { name: string; }
interface HasTimestamps { createdAt: Date; updatedAt: Date; }
// An intersection: must have ALL properties from ALL three interfaces
type BaseEntity = HasId & HasName & HasTimestamps;
const user: BaseEntity = {
id: 1,
name: "Alice",
createdAt: new Date(),
updatedAt: new Date(),
}; // All three groups must be present
// Practical pattern: extending a third-party type without interface merging
import { Request } from "express";
type AuthRequest = Request & { user: User; sessionId: string };
function protectedHandler(req: AuthRequest, res: Response) {
console.log(req.user.name); // user is typed — from intersection
console.log(req.headers.host); // headers is typed — from Request
}
When intersected types have the same property name with incompatible types, the result is never — a silent footgun:
type A = { value: string };
type B = { value: number };
type AB = A & B;
// AB['value'] is string & number = never
const x: AB = { value: ??? }; // Impossible — nothing is both string and number
// TypeScript will not error here at definition time — the error appears when you try
// to assign a value: string is not assignable to never
Counterintuitively, union types have fewer members accessible (only the intersection of their property sets) while intersection types have more members accessible (the union of all property sets):
interface Cat { meow(): void; name: string; }
interface Dog { bark(): void; name: string; }
// Union Cat | Dog — only 'name' is accessible without narrowing
// (it's the only property that exists on BOTH Cat AND Dog)
function greet(pet: Cat | Dog) {
pet.name; // OK — both have name
pet.meow(); // Error — Dog has no meow()
pet.bark(); // Error — Cat has no bark()
}
// Intersection Cat & Dog — ALL properties from both are accessible
function greetSuperPet(pet: Cat & Dog) {
pet.name; // OK
pet.meow(); // OK — must be a Cat
pet.bark(); // OK — must also be a Dog
}Many developers think union types allow access to all member properties — union types only allow access to properties that exist on every member. You must narrow to a specific member to access unique properties.
Many developers think intersection types produce fewer properties than their constituents — the opposite is true. An intersection A & B has all properties from A plus all properties from B. It's wider, not narrower.
Many developers think conflicting properties in intersections cause a compile error at definition time — TypeScript silently produces never for the conflicting property. The error only surfaces when you try to assign a value to that property.
Many developers use discriminated unions only for error handling or state machines — they are equally valuable for any heterogeneous collection: API action types, DOM event variants, form field configurations, and router route descriptors.
Many developers think string literal unions are just documentation — they are enforced at compile time. Passing 'norht' (typo) to a Direction parameter is a compile error, not a runtime surprise.
Many developers think intersections and extends are exactly equivalent — interface extends errors immediately on conflicting properties. Type intersections silently produce never. This difference matters when composing types from external sources.
Redux action types: every Redux codebase models actions as discriminated unions. Each action has a unique type literal, and reducers switch on it — TypeScript narrows to the exact action shape in each case, eliminating all action.payload as Type casts.
React component variants: a Button component might accept type: 'primary' | 'secondary' | 'danger' as a literal union prop — TypeScript enforces valid variants at every call site with no runtime cost.
Express middleware composition: AuthRequest = Request & { user: User } is the standard pattern for typed middleware that adds properties to the request — the intersection combines the library type with your application's custom additions.
API result modeling: ApiResponse<T> = { ok: true; data: T } | { ok: false; error: ApiError } is a discriminated union that forces every consumer to handle both success and failure before accessing the data.
GraphQL fragment composition: tools like GraphQL Code Generator use intersection types to model fragments — a query result type is the intersection of multiple fragment types, ensuring all queried fields are available.
Event handler unions: addEventListener callback types use union literals for the event name to provide fully typed events — 'click' gives MouseEvent, 'keydown' gives KeyboardEvent — powered by overloads and union mapping in TypeScript's DOM lib.
What are union types and intersection types? When do you use each?
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.