Skip to main content

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:

  • typeof for primitives
  • instanceof for classes
  • in for 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 →