Union and intersection types are powerful tools for combining existing types into new ones. Union types (|) represent values that can be one of several types, while intersection types (&) combine multiple types into one. These features give you incredible flexibility in modeling complex data structures. Let's master both!
Union Types
A union type describes a value that can be one of several types. Use the | operator to create unions. Think of it as "OR" logic for types.
Basic Union Types
// Simple union type
let value: string | number;
value = "hello"; // ✅ Works (string)
value = 42; // ✅ Works (number)
// value = true; // ❌ Error: boolean not in union
// Union with multiple types
let id: string | number | boolean;
id = "user-123"; // ✅ string
id = 123; // ✅ number
id = true; // ✅ boolean
// Function with union parameter
function printId(id: string | number): void {
console.log(`ID: ${id}`);
}
printId(123); // ✅ Works
printId("abc-123"); // ✅ Works
// printId(true); // ❌ Error
// Union type alias
type ID = string | number;
let userId: ID = 123;
let productId: ID = "prod-456";
// Array of union types
let mixed: (string | number)[] = [1, "two", 3, "four"];
console.log(mixed); // [1, "two", 3, "four"]
// Union with null/undefined
let nullable: string | null = null;
let optional: number | undefined = undefined;
nullable = "value"; // ✅ Works
optional = 42; // ✅ WorksWorking with Union Types
// Only common properties are accessible
type Dog = {
name: string;
breed: string;
bark(): void;
};
type Cat = {
name: string;
color: string;
meow(): void;
};
type Pet = Dog | Cat;
function getPetName(pet: Pet): string {
// Can access 'name' - exists on both types
return pet.name; // ✅ Works
// Cannot access 'breed' - only on Dog
// return pet.breed; // ❌ Error
// Cannot access 'color' - only on Cat
// return pet.color; // ❌ Error
}
// Must narrow type to access specific properties
function describePet(pet: Pet): void {
console.log(`Name: ${pet.name}`); // ✅ Common property
// Type narrowing with 'in' operator
if ("breed" in pet) {
// TypeScript knows pet is Dog here
console.log(`Breed: ${pet.breed}`);
pet.bark();
} else {
// TypeScript knows pet is Cat here
console.log(`Color: ${pet.color}`);
pet.meow();
}
}
const dog: Dog = {
name: "Max",
breed: "Golden Retriever",
bark() { console.log("Woof!"); }
};
const cat: Cat = {
name: "Whiskers",
color: "Orange",
meow() { console.log("Meow!"); }
};
describePet(dog); // "Name: Max" "Breed: Golden Retriever" "Woof!"
describePet(cat); // "Name: Whiskers" "Color: Orange" "Meow!"Union Type Structure
Can be EITHER User OR Guest
type User = { name: string; age: number };
type Guest = { sessionId: string; temporary: boolean };
type Visitor = User | Guest;Registered user with name and age
Temporary guest with session ID
Type Narrowing
Type narrowing is how you work with union types safely. You narrow a union type down to a specific type using type guards.
typeof Type Guards
// Using typeof for primitive types
function formatValue(value: string | number): string {
if (typeof value === "string") {
// TypeScript knows value is string here
return value.toUpperCase();
}
// TypeScript knows value is number here
return value.toFixed(2);
}
console.log(formatValue("hello")); // "HELLO"
console.log(formatValue(123.456)); // "123.46"
// Multiple type checks
function process(value: string | number | boolean): string {
if (typeof value === "string") {
return `String: ${value}`;
}
if (typeof value === "number") {
return `Number: ${value.toFixed(2)}`;
}
// TypeScript knows value is boolean here
return `Boolean: ${value ? "Yes" : "No"}`;
}
console.log(process("hello")); // "String: hello"
console.log(process(42)); // "Number: 42.00"
console.log(process(true)); // "Boolean: Yes"in Operator Type Guards
// Using 'in' to check for properties
type User = {
name: string;
email: string;
role: "user";
};
type Admin = {
name: string;
email: string;
role: "admin";
permissions: string[];
};
type Account = User | Admin;
function getAccountInfo(account: Account): void {
console.log(`Name: ${account.name}`);
console.log(`Email: ${account.email}`);
console.log(`Role: ${account.role}`);
// Check if 'permissions' property exists
if ("permissions" in account) {
// TypeScript knows account is Admin here
console.log(`Permissions: ${account.permissions.join(", ")}`);
} else {
// TypeScript knows account is User here
console.log("Regular user - no special permissions");
}
}
const user: User = {
name: "Yusuf Ibrahim",
email: "yusuf@example.com",
role: "user"
};
const admin: Admin = {
name: "Amina Hassan",
email: "amina@example.com",
role: "admin",
permissions: ["read", "write", "delete"]
};
getAccountInfo(user);
getAccountInfo(admin);instanceof Type Guards
// Using instanceof with classes
class Dog {
constructor(public name: string, public breed: string) {}
bark(): void {
console.log("Woof! Woof!");
}
}
class Cat {
constructor(public name: string, public color: string) {}
meow(): void {
console.log("Meow!");
}
}
type Pet = Dog | Cat;
function playWithPet(pet: Pet): void {
console.log(`Playing with ${pet.name}`);
if (pet instanceof Dog) {
// TypeScript knows pet is Dog here
console.log(`Breed: ${pet.breed}`);
pet.bark();
} else {
// TypeScript knows pet is Cat here
console.log(`Color: ${pet.color}`);
pet.meow();
}
}
const dog = new Dog("Max", "Labrador");
const cat = new Cat("Whiskers", "Gray");
playWithPet(dog);
playWithPet(cat);Custom Type Guards
// Create custom type guard functions
type Fish = {
swim(): void;
};
type Bird = {
fly(): void;
};
type Animal = Fish | Bird;
// Custom type guard with 'is' keyword
function isFish(animal: Animal): animal is Fish {
return (animal as Fish).swim !== undefined;
}
function isBird(animal: Animal): animal is Bird {
return (animal as Bird).fly !== undefined;
}
// Using custom type guards
function moveAnimal(animal: Animal): void {
if (isFish(animal)) {
// TypeScript knows animal is Fish here
animal.swim();
} else {
// TypeScript knows animal is Bird here
animal.fly();
}
}
const fish: Fish = {
swim() { console.log("Swimming..."); }
};
const bird: Bird = {
fly() { console.log("Flying..."); }
};
moveAnimal(fish); // "Swimming..."
moveAnimal(bird); // "Flying..."
// More complex custom type guard
type ApiSuccess = {
status: "success";
data: any;
};
type ApiError = {
status: "error";
message: string;
};
type ApiResponse = ApiSuccess | ApiError;
function isSuccess(response: ApiResponse): response is ApiSuccess {
return response.status === "success";
}
function handleResponse(response: ApiResponse): void {
if (isSuccess(response)) {
// TypeScript knows response is ApiSuccess
console.log("Data:", response.data);
} else {
// TypeScript knows response is ApiError
console.error("Error:", response.message);
}
}Discriminated Unions
Discriminated unions (also called tagged unions) use a common property to distinguish between union members. This is one of the most powerful patterns in TypeScript.
// Shape types with discriminant property 'kind'
type Circle = {
kind: "circle";
radius: number;
};
type Square = {
kind: "square";
size: number;
};
type Rectangle = {
kind: "rectangle";
width: number;
height: number;
};
type Shape = Circle | Square | Rectangle;
// TypeScript uses 'kind' to narrow the type
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2;
case "square":
// TypeScript knows shape is Square here
return shape.size ** 2;
case "rectangle":
// TypeScript knows shape is Rectangle here
return shape.width * shape.height;
}
}
const circle: Circle = { kind: "circle", radius: 5 };
const square: Square = { kind: "square", size: 10 };
const rectangle: Rectangle = { kind: "rectangle", width: 20, height: 15 };
console.log(`Circle area: ${getArea(circle)}`); // 78.54
console.log(`Square area: ${getArea(square)}`); // 100
console.log(`Rectangle area: ${getArea(rectangle)}`); // 300Practical Example: State Management
// Loading states with discriminated union
type LoadingState = {
status: "loading";
};
type SuccessState<T> = {
status: "success";
data: T;
};
type ErrorState = {
status: "error";
error: string;
};
type DataState<T> = LoadingState | SuccessState<T> | ErrorState;
// User data example
type User = {
id: number;
name: string;
email: string;
};
function renderUserProfile(state: DataState<User>): string {
switch (state.status) {
case "loading":
return "Loading user profile...";
case "success":
// TypeScript knows state has 'data' property
return `Welcome, ${state.data.name} (${state.data.email})`;
case "error":
// TypeScript knows state has 'error' property
return `Error: ${state.error}`;
}
}
// Different states
const loading: DataState<User> = {
status: "loading"
};
const success: DataState<User> = {
status: "success",
data: {
id: 1,
name: "Yusuf Ibrahim",
email: "yusuf@example.com"
}
};
const error: DataState<User> = {
status: "error",
error: "Failed to load user"
};
console.log(renderUserProfile(loading)); // "Loading user profile..."
console.log(renderUserProfile(success)); // "Welcome, Yusuf Ibrahim..."
console.log(renderUserProfile(error)); // "Error: Failed to load user"Actions Pattern
// Redux-style action types
type LoginAction = {
type: "LOGIN";
payload: {
username: string;
password: string;
};
};
type LogoutAction = {
type: "LOGOUT";
};
type UpdateProfileAction = {
type: "UPDATE_PROFILE";
payload: {
name: string;
email: string;
};
};
type Action = LoginAction | LogoutAction | UpdateProfileAction;
// Reducer function with exhaustive checking
function reducer(action: Action): void {
switch (action.type) {
case "LOGIN":
// TypeScript knows action has payload with username and password
console.log(`Logging in: ${action.payload.username}`);
break;
case "LOGOUT":
// TypeScript knows action has no payload
console.log("Logging out");
break;
case "UPDATE_PROFILE":
// TypeScript knows action has payload with name and email
console.log(`Updating profile: ${action.payload.name}`);
break;
default:
// Exhaustive check - ensures all cases handled
const exhaustiveCheck: never = action;
throw new Error(`Unhandled action: ${exhaustiveCheck}`);
}
}
// Dispatch actions
reducer({
type: "LOGIN",
payload: { username: "yusuf", password: "secret" }
});
reducer({ type: "LOGOUT" });
reducer({
type: "UPDATE_PROFILE",
payload: { name: "Yusuf Ibrahim", email: "yusuf@example.com" }
});Intersection Types
An intersection type combines multiple types into one. The resulting type has all properties from all types. Use the & operator to create intersections. Think of it as "AND" logic for types.
Basic Intersection Types
// Combining types with intersection
type Person = {
name: string;
age: number;
};
type Employee = {
employeeId: number;
department: string;
};
// Intersection requires ALL properties from both types
type WorkingPerson = Person & Employee;
const employee: WorkingPerson = {
name: "Yusuf Ibrahim",
age: 25,
employeeId: 1001,
department: "Engineering"
};
console.log(employee);
// Missing any property causes error
// const invalid: WorkingPerson = {
// name: "Amina",
// age: 30,
// employeeId: 1002
// // ❌ Error: Missing 'department'
// };
// Three-way intersection
type Contact = {
email: string;
phone: string;
};
type FullProfile = Person & Employee & Contact;
const profile: FullProfile = {
name: "Chidi Okafor",
age: 28,
employeeId: 1003,
department: "Sales",
email: "chidi@example.com",
phone: "+234-805-987-6543"
};
console.log(profile);Intersection with Interfaces
// Intersecting interfaces
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: number;
}
interface Auditable {
createdBy: number;
updatedBy: number;
}
// Combine multiple interfaces
type DatabaseEntity = Timestamped & Identifiable & Auditable;
const record: DatabaseEntity = {
id: 1,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-12-25"),
createdBy: 101,
updatedBy: 102
};
// Use with additional properties
type Product = DatabaseEntity & {
name: string;
price: number;
};
const product: Product = {
id: 1,
name: "Laptop",
price: 450000,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 1,
updatedBy: 1
};
console.log(product);Intersection with Function Types
// Intersecting function types
type Logger = {
log(message: string): void;
};
type ErrorHandler = {
handleError(error: Error): void;
};
type Service = Logger & ErrorHandler;
const service: Service = {
log(message: string): void {
console.log(`[LOG] ${message}`);
},
handleError(error: Error): void {
console.error(`[ERROR] ${error.message}`);
}
};
service.log("Service started");
service.handleError(new Error("Something went wrong"));
// Intersection with callable signature
type Callable = {
(): void;
};
type WithProperties = {
value: number;
};
type CallableWithProps = Callable & WithProperties;
const func: CallableWithProps = Object.assign(
() => console.log("Called!"),
{ value: 42 }
);
func(); // "Called!"
console.log(func.value); // 42Union vs Intersection
| Feature | Union (A | B) | Intersection (A & B) |
|---|---|---|
| Meaning | OR - can be A OR B | AND - must be A AND B |
| Properties | Only common properties accessible | All properties from both types |
| Flexibility | More flexible (less strict) | Less flexible (more strict) |
| Use case | Different possible types | Combining features/mixins |
| Type narrowing | Usually required | Not required |
type HasName = { name: string };
type HasAge = { age: number };
// UNION: Can be HasName OR HasAge (or both)
type UnionType = HasName | HasAge;
const union1: UnionType = { name: "Yusuf" }; // ✅ Only HasName
const union2: UnionType = { age: 25 }; // ✅ Only HasAge
const union3: UnionType = { name: "Amina", age: 30 }; // ✅ Both
// Can only access properties that exist on BOTH types
function printUnion(value: UnionType): void {
// console.log(value.name); // ❌ Error: name doesn't exist on HasAge
// console.log(value.age); // ❌ Error: age doesn't exist on HasName
// Must narrow type first
if ("name" in value) {
console.log(value.name); // ✅ Works after narrowing
}
}
// INTERSECTION: Must have properties from BOTH HasName AND HasAge
type IntersectionType = HasName & HasAge;
const inter1: IntersectionType = { name: "Yusuf", age: 25 }; // ✅ Has both
// const inter2: IntersectionType = { name: "Amina" }; // ❌ Missing age
// const inter3: IntersectionType = { age: 30 }; // ❌ Missing name
// Can access ALL properties without narrowing
function printIntersection(value: IntersectionType): void {
console.log(value.name); // ✅ Always available
console.log(value.age); // ✅ Always available
}
printIntersection({ name: "Chidi", age: 28 });Practical Examples
Example 1: API Response Types
// Base response structure
type BaseResponse = {
timestamp: Date;
requestId: string;
};
// Success response
type SuccessResponse<T> = BaseResponse & {
status: "success";
data: T;
};
// Error response
type ErrorResponse = BaseResponse & {
status: "error";
error: {
code: string;
message: string;
};
};
// Union of possible responses
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
// User type
type User = {
id: number;
name: string;
email: string;
};
// Handle API response
function handleUserResponse(response: ApiResponse<User>): void {
// Common properties always available
console.log(`Request ID: ${response.requestId}`);
console.log(`Timestamp: ${response.timestamp}`);
// Discriminated union with status
if (response.status === "success") {
// TypeScript knows response has 'data'
console.log(`User: ${response.data.name}`);
console.log(`Email: ${response.data.email}`);
} else {
// TypeScript knows response has 'error'
console.error(`Error ${response.error.code}: ${response.error.message}`);
}
}
// Mock responses
const success: ApiResponse<User> = {
status: "success",
timestamp: new Date(),
requestId: "req-123",
data: {
id: 1,
name: "Yusuf Ibrahim",
email: "yusuf@example.com"
}
};
const error: ApiResponse<User> = {
status: "error",
timestamp: new Date(),
requestId: "req-124",
error: {
code: "USER_NOT_FOUND",
message: "User with given ID not found"
}
};
handleUserResponse(success);
handleUserResponse(error);Example 2: Payment Methods
// Different payment method types
type CashPayment = {
method: "cash";
amount: number;
currency: string;
};
type CardPayment = {
method: "card";
amount: number;
currency: string;
cardNumber: string;
cardHolder: string;
};
type BankTransfer = {
method: "bank_transfer";
amount: number;
currency: string;
accountNumber: string;
bankName: string;
};
type MobileMoney = {
method: "mobile_money";
amount: number;
currency: string;
phoneNumber: string;
provider: string;
};
// Union of all payment methods
type Payment = CashPayment | CardPayment | BankTransfer | MobileMoney;
// Process payment based on method
function processPayment(payment: Payment): string {
// Common properties available
console.log(`Processing ${payment.currency}${payment.amount}`);
switch (payment.method) {
case "cash":
return `Cash payment of ${payment.currency}${payment.amount} received`;
case "card":
return `Card payment from ${payment.cardHolder} (****${payment.cardNumber.slice(-4)})`;
case "bank_transfer":
return `Bank transfer from ${payment.bankName} account ${payment.accountNumber}`;
case "mobile_money":
return `Mobile money from ${payment.provider}: ${payment.phoneNumber}`;
default:
const exhaustive: never = payment;
throw new Error(`Unhandled payment method: ${exhaustive}`);
}
}
// Different payment instances
const cash: Payment = {
method: "cash",
amount: 10000,
currency: "₦"
};
const card: Payment = {
method: "card",
amount: 50000,
currency: "₦",
cardNumber: "1234567812345678",
cardHolder: "Yusuf Ibrahim"
};
const transfer: Payment = {
method: "bank_transfer",
amount: 100000,
currency: "₦",
accountNumber: "0123456789",
bankName: "GTBank"
};
const mobile: Payment = {
method: "mobile_money",
amount: 5000,
currency: "₦",
phoneNumber: "+234-803-123-4567",
provider: "MTN"
};
console.log(processPayment(cash));
console.log(processPayment(card));
console.log(processPayment(transfer));
console.log(processPayment(mobile));Example 3: Mixin Pattern with Intersection
// Base functionality
type Printable = {
print(): void;
};
type Serializable = {
toJSON(): string;
};
type Comparable = {
equals(other: any): boolean;
};
// Combine functionalities with intersection
type FullFeatured = Printable & Serializable & Comparable;
// Implement all features
class Document implements FullFeatured {
constructor(
public title: string,
public content: string
) {}
print(): void {
console.log(`=== ${this.title} ===`);
console.log(this.content);
}
toJSON(): string {
return JSON.stringify({
title: this.title,
content: this.content
});
}
equals(other: Document): boolean {
return this.title === other.title &&
this.content === other.content;
}
}
const doc1 = new Document("Report", "This is the report content");
const doc2 = new Document("Report", "This is the report content");
const doc3 = new Document("Letter", "This is a letter");
doc1.print();
// === Report ===
// This is the report content
console.log(doc1.toJSON());
// {"title":"Report","content":"This is the report content"}
console.log(doc1.equals(doc2)); // true
console.log(doc1.equals(doc3)); // falseTest Your Knowledge
Type Safety Check
Which type combination is valid?
type A = { name: string; age: number };
type B = { email: string; phone: string };
// Option 1
type Union = A | B;
const user1: Union = { name: "Yusuf", age: 25 };
// Option 2
type Intersection = A & B;
const user2: Intersection = { name: "Yusuf", age: 25 };
// Option 3
type Intersection = A & B;
const user3: Intersection = {
name: "Yusuf",
age: 25,
email: "yusuf@example.com",
phone: "+234-803-123-4567"
};
// Option 4
type Union = A | B;
const user4: Union = { name: "Yusuf" };Common TypeScript Error
// Trying to access property without type narrowing
type User = { name: string; age: number };
type Guest = { sessionId: string; temporary: boolean };
type Visitor = User | Guest;
function displayVisitor(visitor: Visitor) {
// Error: Property 'name' does not exist on type 'Visitor'
console.log(visitor.name);
// Error: Property 'sessionId' does not exist on type 'Visitor'
console.log(visitor.sessionId);
}❌ Property 'name' does not exist on type 'Visitor'. Property 'sessionId' does not exist on type 'Visitor'.
What's Wrong?
With union types, you can only access properties that exist on ALL types in the union. Since 'name' doesn't exist on Guest and 'sessionId' doesn't exist on User, TypeScript prevents access.
// Fixed: Use type narrowing
type User = { name: string; age: number };
type Guest = { sessionId: string; temporary: boolean };
type Visitor = User | Guest;
function displayVisitor(visitor: Visitor) {
// Type guard to narrow the type
if ("name" in visitor) {
// TypeScript knows visitor is User here
console.log(`User: ${visitor.name}, Age: ${visitor.age}`);
} else {
// TypeScript knows visitor is Guest here
console.log(`Guest: ${visitor.sessionId}, Temporary: ${visitor.temporary}`);
}
}
// Usage
displayVisitor({ name: "Yusuf", age: 25 });
displayVisitor({ sessionId: "abc123", temporary: true });Solution: Use type narrowing to determine which type you have
Best Practices
- Use discriminated unions for better type narrowing
- Prefer union types for "one of several" scenarios
- Use intersection types for combining features/mixins
- Always narrow union types before accessing specific properties
- Use never for exhaustive checks in switch statements
- Keep discriminant properties simple (strings or numbers)
- Document complex unions with comments
- Avoid deep nesting of unions and intersections
// ✅ Good: Discriminated union with clear discriminant
type Success = { status: "success"; data: string };
type Error = { status: "error"; message: string };
type Result = Success | Error;
function handleResult(result: Result): void {
if (result.status === "success") {
console.log(result.data);
} else {
console.error(result.message);
}
}
// ✅ Good: Intersection for combining features
type Timestamped = { createdAt: Date };
type Identifiable = { id: number };
type Entity = Timestamped & Identifiable;
// ✅ Good: Exhaustive checking with never
type Action = { type: "login" } | { type: "logout" };
function reducer(action: Action): void {
switch (action.type) {
case "login":
return;
case "logout":
return;
default:
const exhaustive: never = action;
throw new Error(`Unhandled: ${exhaustive}`);
}
}
// ❌ Avoid: Non-discriminated unions (harder to narrow)
type Bad = { name: string } | { title: string };
// Hard to tell which type you have
// ❌ Avoid: Deep nesting
type Messy = (A | B) & (C | D) & (E | F);
// Hard to understand and maintain
// ✅ Better: Break into smaller types
type Combined1 = A | B;
type Combined2 = C | D;
type Combined3 = E | F;
type Better = Combined1 & Combined2 & Combined3;Key Takeaways
- Union types (
|) mean "OR" - can be one of several types - Intersection types (
&) mean "AND" - must have all properties - Union types require type narrowing to access specific properties
- Intersection types give immediate access to all properties
- Discriminated unions use a common property for type narrowing
- Use
typeof,in,instanceoffor type guards - Custom type guards use
iskeyword - Exhaustive checking with
neverensures all cases are handled - Union types are more flexible, intersection types are more strict
- Combine union and intersection for powerful type definitions
What's Next?
Fantastic! You've mastered union and intersection types, one of TypeScript's most powerful features. Next, we'll explore Type Guards and Type Assertions in even more depth. You'll learn advanced narrowing techniques, type predicates, assertion functions, and how to safely work with types at runtime. These skills will make you a TypeScript expert!
Get ready to become a type narrowing master!