Difficulty: Intermediate
Explain union and intersection types. How do they differ and when would you use each?
Union types (A | B) represent values that can be ONE of several types. You can only access properties common to all members unless you narrow the type first.
Intersection types (A & B) combine multiple types into one that has ALL properties from every type. The result must satisfy every constituent type.
Key differences: - Union: value is A OR B (wider, more flexible) - Intersection: value is A AND B (narrower, more specific) - Union of objects: only shared properties are accessible - Intersection of objects: all properties from all types are accessible
Unions model alternatives. Intersections model composition/mixins.
// Basic union
type ID = string | number;
function findUser(id: ID): User | undefined {
// Must narrow before using type-specific methods
if (typeof id === 'string') {
return users.find(u => u.slug === id);
}
return users.find(u => u.id === id);
}
// Union of object types
type SuccessResponse = {
status: 'success';
data: unknown;
};
type ErrorResponse = {
status: 'error';
error: string;
code: number;
};
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(res: ApiResponse) {
if (res.status === 'success') {
console.log(res.data); // OK: narrowed to SuccessResponse
} else {
console.log(res.error); // OK: narrowed to ErrorResponse
console.log(res.code);
}
}
Unions with a discriminant field (like 'status') enable safe narrowing. Without narrowing, only shared properties are accessible.
// Composing types with intersection
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type SoftDeletable = {
deletedAt: Date | null;
isDeleted: boolean;
};
type Identifiable = {
id: string;
};
// Combine into a full entity type
type BaseEntity = Identifiable & Timestamped & SoftDeletable;
interface User extends BaseEntity {
name: string;
email: string;
}
// Mixin function using intersection
function withLogging<T>(obj: T): T & { log: (msg: string) => void } {
return {
...obj,
log(msg: string) {
console.log(`[${new Date().toISOString()}] ${msg}`);
},
};
}
const user = withLogging({ name: 'Alice', role: 'admin' });
user.name; // string
user.log('action performed'); // works
Intersections are ideal for composing reusable type fragments. Each fragment adds capabilities without modifying the original types.
// Intersection of primitives = never
type Impossible = string & number; // never
// Intersection of unions = narrower
type A = 'a' | 'b' | 'c';
type B = 'b' | 'c' | 'd';
type Both = A & B; // 'b' | 'c' (only shared members)
// Union of intersections (common pattern)
type Event =
| { type: 'click'; x: number; y: number }
| { type: 'keypress'; key: string; code: number }
| { type: 'scroll'; deltaX: number; deltaY: number };
function handleEvent(event: Event) {
switch (event.type) {
case 'click':
console.log(event.x, event.y); // narrowed
break;
case 'keypress':
console.log(event.key, event.code); // narrowed
break;
case 'scroll':
console.log(event.deltaX, event.deltaY); // narrowed
break;
}
}
// Intersection with conflicting properties
type Left = { value: string };
type Right = { value: number };
type Conflict = Left & Right;
// value: string & number => never (impossible to satisfy)
Intersecting primitive types or conflicting property types produces 'never'. Intersecting unions keeps only the shared members.
Union Types, Intersection Types, Type Narrowing, Distributive Unions, Combining Types