Union & Intersection Types
Introduction
Union and intersection types are powerful TypeScript features that let you combine types in different ways. They're essential for modeling complex data structures and creating flexible APIs.
- Union Types (
|) - A value can be one of several types (OR) - Intersection Types (
&) - A value must have all properties from multiple types (AND)
Think of unions as "either/or" and intersections as "both/and".
Real-World Analogy
Union Type (OR):
A payment can be:
- Credit Card OR
- PayPal OR
- Bank Transfer
You choose ONE method.
Intersection Type (AND):
A superhero must be:
- Person AND
- SuperPowers
They have BOTH characteristics.
Concepts
Union Types (OR)
A union type means a value can be one of several types:
// Can be string OR number
let id: string | number;
id = "ABC123"; // ✅ Valid
id = 123; // ✅ Valid
// id = true; // ❌ Error: boolean not allowed
function printId(id: string | number): void {
console.log(`ID: ${id}`);
}
printId("ABC123"); // ✅
printId(456); // ✅
Multiple Union Types
type Status = "pending" | "approved" | "rejected";
let orderStatus: Status;
orderStatus = "pending"; // ✅
orderStatus = "approved"; // ✅
// orderStatus = "cancelled"; // ❌ Error
type Result = number | string | boolean;
let value: Result;
value = 42; // ✅
value = "hello"; // ✅
value = true; // ✅
Intersection Types (AND)
An intersection type means a value must have all properties from multiple types:
type Person = {
name: string;
age: number;
};
type Employee = {
employeeId: number;
department: string;
};
// Must have ALL properties from both types
type Staff = Person & Employee;
const staff: Staff = {
name: "Alice",
age: 30,
employeeId: 1001,
department: "Engineering"
};
// Missing any property would be an error
Union of Object Types
type Dog = {
type: "dog";
bark: () => void;
};
type Cat = {
type: "cat";
meow: () => void;
};
type Pet = Dog | Cat;
function handlePet(pet: Pet): void {
// TypeScript knows pet could be either type
console.log(pet.type);
// Must check which type before using specific methods
if (pet.type === "dog") {
pet.bark(); // ✅ TypeScript knows it's a Dog
} else {
pet.meow(); // ✅ TypeScript knows it's a Cat
}
}
Discriminated Unions (Tagged Unions)
A common pattern using a literal type as a discriminator:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
type Shape = Circle | Square | Rectangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "rectangle":
return shape.width * shape.height;
}
}
const circle: Circle = { kind: "circle", radius: 5 };
const square: Square = { kind: "square", sideLength: 10 };
console.log(getArea(circle)); // 78.54
console.log(getArea(square)); // 100
Combining Unions and Intersections
type Admin = {
role: "admin";
permissions: string[];
};
type User = {
role: "user";
name: string;
};
type Guest = {
role: "guest";
};
// Union of all possible account types
type Account = Admin | User | Guest;
// Intersection: Account that also has an ID
type IdentifiedAccount = Account & { id: number };
const adminAccount: IdentifiedAccount = {
id: 1,
role: "admin",
permissions: ["read", "write", "delete"]
};
Literal Types in Unions
type Direction = "north" | "south" | "east" | "west";
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
type YesNo = "yes" | "no";
function move(direction: Direction): void {
console.log(`Moving ${direction}`);
}
move("north"); // ✅
// move("up"); // ❌ Error
let roll: DiceRoll = 4; // ✅
// roll = 7; // ❌ Error
Narrowing Union Types
TypeScript narrows types based on checks:
function processValue(value: string | number): void {
// TypeScript doesn't know which type yet
if (typeof value === "string") {
// TypeScript knows value is string here
console.log(value.toUpperCase());
} else {
// TypeScript knows value is number here
console.log(value.toFixed(2));
}
}
// With arrays
function printLength(value: string | any[]): void {
if (Array.isArray(value)) {
console.log(`Array length: ${value.length}`);
} else {
console.log(`String length: ${value.length}`);
}
}
Examples
Payment Processing System
interface CreditCardPayment {
method: "credit_card";
cardNumber: string;
cvv: string;
expiryDate: string;
}
interface PayPalPayment {
method: "paypal";
email: string;
}
interface BankTransferPayment {
method: "bank_transfer";
accountNumber: string;
routingNumber: string;
}
type Payment = CreditCardPayment | PayPalPayment | BankTransferPayment;
class PaymentProcessor {
processPayment(payment: Payment, amount: number): void {
switch (payment.method) {
case "credit_card":
console.log(`Processing credit card payment of $${amount}`);
console.log(`Card ending in ${payment.cardNumber.slice(-4)}`);
break;
case "paypal":
console.log(`Processing PayPal payment of $${amount}`);
console.log(`PayPal account: ${payment.email}`);
break;
case "bank_transfer":
console.log(`Processing bank transfer of $${amount}`);
console.log(`Account: ${payment.accountNumber}`);
break;
}
}
}
// Usage
const processor = new PaymentProcessor();
const creditCard: CreditCardPayment = {
method: "credit_card",
cardNumber: "1234567890123456",
cvv: "123",
expiryDate: "12/25"
};
const paypal: PayPalPayment = {
method: "paypal",
email: "user@example.com"
};
processor.processPayment(creditCard, 100);
processor.processPayment(paypal, 50);
API Response Handler
interface SuccessResponse<T> {
status: "success";
data: T;
timestamp: Date;
}
interface ErrorResponse {
status: "error";
error: string;
code: number;
timestamp: Date;
}
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
class ApiClient {
async fetchUser(id: number): Promise<ApiResponse<User>> {
try {
// Simulate API call
await this.delay(1000);
if (id > 0) {
return {
status: "success",
data: { id, name: "Alice", email: "alice@example.com" },
timestamp: new Date()
};
} else {
return {
status: "error",
error: "Invalid user ID",
code: 400,
timestamp: new Date()
};
}
} catch (error) {
return {
status: "error",
error: "Network error",
code: 500,
timestamp: new Date()
};
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
handleResponse<T>(response: ApiResponse<T>): void {
if (response.status === "success") {
console.log("Data:", response.data);
} else {
console.error(`Error ${response.code}: ${response.error}`);
}
}
}
interface User {
id: number;
name: string;
email: string;
}
// Usage
const client = new ApiClient();
async function example() {
const response = await client.fetchUser(1);
client.handleResponse(response);
}
Event System with Union Types
interface ClickEvent {
type: "click";
x: number;
y: number;
timestamp: Date;
}
interface KeyPressEvent {
type: "keypress";
key: string;
timestamp: Date;
}
interface MouseMoveEvent {
type: "mousemove";
x: number;
y: number;
timestamp: Date;
}
type UIEvent = ClickEvent | KeyPressEvent | MouseMoveEvent;
class EventLogger {
logEvent(event: UIEvent): void {
switch (event.type) {
case "click":
console.log(`Click at (${event.x}, ${event.y})`);
break;
case "keypress":
console.log(`Key pressed: ${event.key}`);
break;
case "mousemove":
console.log(`Mouse moved to (${event.x}, ${event.y})`);
break;
}
}
filterEventsByType<T extends UIEvent["type"]>(
events: UIEvent[],
type: T
): Extract<UIEvent, { type: T }>[] {
return events.filter((e): e is Extract<UIEvent, { type: T }> => e.type === type);
}
}
// Usage
const logger = new EventLogger();
const events: UIEvent[] = [
{ type: "click", x: 100, y: 200, timestamp: new Date() },
{ type: "keypress", key: "Enter", timestamp: new Date() },
{ type: "mousemove", x: 150, y: 250, timestamp: new Date() }
];
events.forEach(event => logger.logEvent(event));
const clicks = logger.filterEventsByType(events, "click");
console.log("Clicks:", clicks);
Intersection Types for Mixins
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type Identifiable = {
id: number;
};
type Versioned = {
version: number;
};
// Combine multiple capabilities
type Entity = Timestamped & Identifiable & Versioned;
interface User extends Entity {
name: string;
email: string;
}
interface Product extends Entity {
name: string;
price: number;
}
function createEntity<T extends Partial<Entity>>(
data: T
): T & Entity {
return {
...data,
id: Math.floor(Math.random() * 10000),
createdAt: new Date(),
updatedAt: new Date(),
version: 1
} as T & Entity;
}
// Usage
const user = createEntity({
name: "Alice",
email: "alice@example.com"
});
console.log(user);
// {
// name: "Alice",
// email: "alice@example.com",
// id: 1234,
// createdAt: Date,
// updatedAt: Date,
// version: 1
// }
Challenges
1. String or Number ID
- Create a type that accepts string or number
- Function that formats ID consistently
- Handle both types
💡 Show Answer
type ID = string | number;
function formatId(id: ID): string {
if (typeof id === "string") {
return id.toUpperCase();
} else {
return `ID-${id.toString().padStart(6, "0")}`;
}
}
console.log(formatId("abc123")); // "ABC123"
console.log(formatId(42)); // "ID-000042"
console.log(formatId("xyz")); // "XYZ"
console.log(formatId(999)); // "ID-000999"
2. Success or Error Response
- Create discriminated union for API response
- Success has data, Error has message
- Type-safe handling function
💡 Show Answer
interface Success<T> {
success: true;
data: T;
}
interface Failure {
success: false;
error: string;
}
type Result<T> = Success<T> | Failure;
function handleResult<T>(result: Result<T>): void {
if (result.success) {
console.log("Success:", result.data);
} else {
console.error("Error:", result.error);
}
}
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return {
success: false,
error: "Division by zero"
};
}
return {
success: true,
data: a / b
};
}
handleResult(divide(10, 2)); // Success: 5
handleResult(divide(10, 0)); // Error: Division by zero
3. Shape Area Calculator
- Different shape types (circle, square, rectangle)
- Use discriminated union
- Calculate area for each type
💡 Show Answer
interface Circle {
type: "circle";
radius: number;
}
interface Square {
type: "square";
side: number;
}
interface Rectangle {
type: "rectangle";
width: number;
height: number;
}
interface Triangle {
type: "triangle";
base: number;
height: number;
}
type Shape = Circle | Square | Rectangle | Triangle;
function calculateArea(shape: Shape): number {
switch (shape.type) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
const shapes: Shape[] = [
{ type: "circle", radius: 5 },
{ type: "square", side: 4 },
{ type: "rectangle", width: 6, height: 3 },
{ type: "triangle", base: 4, height: 5 }
];
shapes.forEach(shape => {
console.log(`${shape.type}: ${calculateArea(shape).toFixed(2)}`);
});
4. Combine Person and Employee
- Person type with name and age
- Employee type with id and department
- Intersection type for staff member
💡 Show Answer
type Person = {
name: string;
age: number;
email: string;
};
type Employee = {
employeeId: number;
department: string;
salary: number;
};
type StaffMember = Person & Employee;
function createStaffMember(
person: Person,
employee: Employee
): StaffMember {
return { ...person, ...employee };
}
const person: Person = {
name: "Alice",
age: 30,
email: "alice@example.com"
};
const employee: Employee = {
employeeId: 1001,
department: "Engineering",
salary: 75000
};
const staff = createStaffMember(person, employee);
console.log(staff);
// {
// name: "Alice",
// age: 30,
// email: "alice@example.com",
// employeeId: 1001,
// department: "Engineering",
// salary: 75000
// }
function printStaffInfo(staff: StaffMember): void {
console.log(`${staff.name} (${staff.employeeId})`);
console.log(`Department: ${staff.department}`);
console.log(`Age: ${staff.age}`);
}
printStaffInfo(staff);
5. Status Type
- Create union of literal strings
- Valid states: pending, processing, completed, failed
- Type-safe status update function
💡 Show Answer
type OrderStatus = "pending" | "processing" | "completed" | "failed";
interface Order {
id: number;
status: OrderStatus;
items: string[];
}
class OrderManager {
private orders: Map<number, Order> = new Map();
private nextId = 1;
createOrder(items: string[]): Order {
const order: Order = {
id: this.nextId++,
status: "pending",
items
};
this.orders.set(order.id, order);
return order;
}
updateStatus(orderId: number, status: OrderStatus): boolean {
const order = this.orders.get(orderId);
if (!order) return false;
order.status = status;
console.log(`Order ${orderId} status updated to: ${status}`);
return true;
}
canTransition(from: OrderStatus, to: OrderStatus): boolean {
const validTransitions: Record<OrderStatus, OrderStatus[]> = {
pending: ["processing", "failed"],
processing: ["completed", "failed"],
completed: [],
failed: []
};
return validTransitions[from].includes(to);
}
}
const manager = new OrderManager();
const order = manager.createOrder(["Item 1", "Item 2"]);
console.log("Can go pending -> processing?",
manager.canTransition("pending", "processing")); // true
manager.updateStatus(order.id, "processing");
manager.updateStatus(order.id, "completed");
6. Array or Single Value
- Function that accepts T or T[]
- Normalize to always return T[]
- Handle both cases
💡 Show Answer
function toArray<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value];
}
console.log(toArray(5)); // [5]
console.log(toArray([1, 2, 3])); // [1, 2, 3]
console.log(toArray("hello")); // ["hello"]
console.log(toArray(["a", "b"])); // ["a", "b"]
function processItems<T>(items: T | T[], processor: (item: T) => void): void {
const array = toArray(items);
array.forEach(processor);
}
processItems(5, (n) => console.log(n * 2));
// 10
processItems([1, 2, 3], (n) => console.log(n * 2));
// 2
// 4
// 6
7. Nullable Type
- Create type that can be T or null
- Utility function to unwrap or provide default
- Handle null safely
💡 Show Answer
type Nullable<T> = T | null;
function unwrapOr `<T>`(value: Nullable `<T>`, defaultValue: T): T {
return value !== null ? value : defaultValue;
}
function isPresent `<T>`(value: Nullable `<T>`): value is T {
return value !== null;
}
// Usage
const maybeString: Nullable `<string>` = "hello";
const maybeNull: Nullable `<string>` = null;
console.log(unwrapOr(maybeString, "default")); // "hello"
console.log(unwrapOr(maybeNull, "default")); // "default"
const values: Nullable `<number>`[] = [1, null, 2, null, 3];
const present = values.filter(isPresent);
console.log(present); // [1, 2, 3]
// Map function that handles nulls
function mapNullable<T, U>(
value: Nullable `<T>`,
fn: (val: T) => U
): Nullable `<U>` {
return value !== null ? fn(value) : null;
}
const result1 = mapNullable("hello", (s) => s.length);
console.log(result1); // 5
const result2 = mapNullable(null, (s: string) => s.length);
console.log(result2); // null
8. Logging Levels
- Union of log levels: debug, info, warn, error
- Logger class with type-safe methods
- Filter logs by level
💡 Show Answer
type LogLevel = "debug" | "info" | "warn" | "error";
interface LogEntry {
level: LogLevel;
message: string;
timestamp: Date;
}
class Logger {
private logs: LogEntry[] = [];
private minLevel: LogLevel = "info";
private levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
setMinLevel(level: LogLevel): void {
this.minLevel = level;
}
private shouldLog(level: LogLevel): boolean {
return this.levelPriority[level] >= this.levelPriority[this.minLevel];
}
private log(level: LogLevel, message: string): void {
if (!this.shouldLog(level)) return;
const entry: LogEntry = {
level,
message,
timestamp: new Date()
};
this.logs.push(entry);
console.log(`[${level.toUpperCase()}] ${message}`);
}
debug(message: string): void {
this.log("debug", message);
}
info(message: string): void {
this.log("info", message);
}
warn(message: string): void {
this.log("warn", message);
}
error(message: string): void {
this.log("error", message);
}
getLogsByLevel(level: LogLevel): LogEntry[] {
return this.logs.filter(log => log.level === level);
}
getAllLogs(): LogEntry[] {
return [...this.logs];
}
}
const logger = new Logger();
logger.setMinLevel("info");
logger.debug("This won't be logged");
logger.info("Application started");
logger.warn("Low memory");
logger.error("Database connection failed");
console.log("\nError logs:");
console.log(logger.getLogsByLevel("error"));
9. Timestamped and Versioned
- Create Timestamped type with dates
- Create Versioned type with version number
- Combine with intersection for Entity
💡 Show Answer
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type Versioned = {
version: number;
};
type Identifiable = {
id: string;
};
type Entity = Timestamped & Versioned & Identifiable;
interface User extends Entity {
name: string;
email: string;
}
interface Product extends Entity {
name: string;
price: number;
inStock: boolean;
}
class EntityManager `<T extends Entity>` {
private entities: Map<string, T> = new Map();
create(data: Omit<T, keyof Entity>): T {
const entity = {
...data,
id: this.generateId(),
createdAt: new Date(),
updatedAt: new Date(),
version: 1
} as T;
this.entities.set(entity.id, entity);
return entity;
}
update(id: string, updates: Partial<Omit<T, keyof Entity>>): T | null {
const entity = this.entities.get(id);
if (!entity) return null;
const updated = {
...entity,
...updates,
updatedAt: new Date(),
version: entity.version + 1
};
this.entities.set(id, updated);
return updated;
}
findById(id: string): T | null {
return this.entities.get(id) || null;
}
private generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
// Usage
const userManager = new EntityManager `<User>`();
const user = userManager.create({
name: "Alice",
email: "alice@example.com"
});
console.log(user);
const updated = userManager.update(user.id, { name: "Alice Smith" });
console.log(updated?.version); // 2
10. Form Field Union
- Different field types (text, number, checkbox, select)
- Discriminated union
- Render function for each type
💡 Show Answer
interface TextField {
type: "text";
label: string;
placeholder?: string;
value: string;
}
interface NumberField {
type: "number";
label: string;
min?: number;
max?: number;
value: number;
}
interface CheckboxField {
type: "checkbox";
label: string;
value: boolean;
}
interface SelectField {
type: "select";
label: string;
options: string[];
value: string;
}
type FormField = TextField | NumberField | CheckboxField | SelectField;
class FormRenderer {
renderField(field: FormField): string {
switch (field.type) {
case "text":
return `<input type="text"
placeholder="${field.placeholder || ''}"
value="${field.value}" />`;
case "number":
return `<input type="number"
min="${field.min || ''}"
max="${field.max || ''}"
value="${field.value}" />`;
case "checkbox":
return `<input type="checkbox"
${field.value ? 'checked' : ''} />`;
case "select":
const options = field.options
.map(opt => `<option${opt === field.value ? ' selected' : ''}>${opt}</option>`)
.join('');
return `<select>${options}</select>`;
}
}
validateField(field: FormField): boolean {
switch (field.type) {
case "text":
return field.value.length > 0;
case "number":
if (field.min !== undefined && field.value < field.min) return false;
if (field.max !== undefined && field.value > field.max) return false;
return true;
case "checkbox":
return true;
case "select":
return field.options.includes(field.value);
}
}
}
// Usage
const fields: FormField[] = [
{
type: "text",
label: "Name",
placeholder: "Enter your name",
value: "Alice"
},
{
type: "number",
label: "Age",
min: 0,
max: 120,
value: 25
},
{
type: "checkbox",
label: "Subscribe to newsletter",
value: true
},
{
type: "select",
label: "Country",
options: ["USA", "UK", "Canada"],
value: "UK"
}
];
const renderer = new FormRenderer();
fields.forEach(field => {
console.log(`${field.label}:`);
console.log(renderer.renderField(field));
console.log(`Valid: ${renderer.validateField(field)}\n`);
});
Summary
In this chapter, you learned:
✅ Union types (|) for "either/or" scenarios
✅ Intersection types (&) for "both/and" scenarios
✅ Discriminated unions for type-safe polymorphism
✅ Literal types in unions
✅ Type narrowing with guards
✅ Combining unions and intersections
✅ Practical patterns and use cases
Key Takeaways:
- Union types represent one of several possible types
- Intersection types combine multiple types into one
- Discriminated unions use a literal property to distinguish types
- TypeScript narrows types based on runtime checks
- Use unions for flexible APIs
- Use intersections to compose types
Common Patterns:
- API responses (success or error)
- Payment methods
- Shape calculations
- Event handling
- Form field types
- State machines
Best Practices:
- Use discriminated unions for complex scenarios
- Choose meaningful discriminator property names
- Handle all cases in switch statements
- Use type guards for narrowing
- Prefer unions over optional properties when appropriate
- Combine with generics for maximum flexibility
Type Guards:
typeoffor primitivesinstanceoffor classesinfor property checks- Custom type predicates with
is - Discriminated union switches
In the next chapter, we'll explore Type Guards & Narrowing in depth!
Continue to Chapter 15: Type Guards & Narrowing →