TypeScript Intersection Types: Combining Types
Intersection types in TypeScript allow you to combine multiple types into a single type that has all properties and capabilities of each constituent type. You create them using the `&` operator, and...
Key Insights
- Intersection types combine multiple types into one using the
&operator, requiring values to satisfy all constituent types simultaneously—the opposite of union types which require satisfying at least one type - Use intersection types for composing behaviors and properties from multiple sources, making them ideal for mixins, configuration merging, and feature composition patterns
- Property conflicts in intersections resolve to
neverwhen incompatible, but function parameters intersect covariantly, creating useful overload-like behavior
Introduction to Intersection Types
Intersection types in TypeScript allow you to combine multiple types into a single type that has all properties and capabilities of each constituent type. You create them using the & operator, and the resulting type requires values to satisfy every type in the intersection.
Think of intersection types as a logical AND operation. While union types (|) mean “this OR that,” intersection types mean “this AND that.” A value must conform to all types simultaneously.
type Person = {
name: string;
age: number;
};
type Employee = {
employeeId: string;
department: string;
};
type EmployeePerson = Person & Employee;
const worker: EmployeePerson = {
name: "Alice",
age: 30,
employeeId: "E12345",
department: "Engineering"
};
The EmployeePerson type requires all four properties. Omit any property from either Person or Employee, and TypeScript will complain.
Practical Use Cases
Intersection types excel at combining data from different domains or layers of your application. Here are scenarios where they provide real value.
Combining Authentication with User Profiles
In most applications, you separate authentication data from profile information. Intersection types let you combine them when needed:
type AuthData = {
userId: string;
token: string;
expiresAt: Date;
};
type UserProfile = {
email: string;
displayName: string;
avatarUrl: string;
};
type AuthenticatedUser = AuthData & UserProfile;
function createSession(auth: AuthData, profile: UserProfile): AuthenticatedUser {
return { ...auth, ...profile };
}
// Usage
const session = createSession(
{ userId: "123", token: "abc", expiresAt: new Date() },
{ email: "user@example.com", displayName: "User", avatarUrl: "/avatar.jpg" }
);
// TypeScript knows about all properties
console.log(session.token);
console.log(session.displayName);
Merging API Responses with Metadata
When wrapping API responses with pagination or caching metadata, intersections keep your types clean:
type ApiResponse<T> = {
data: T;
timestamp: number;
};
type Paginated = {
page: number;
pageSize: number;
totalPages: number;
};
type PaginatedResponse<T> = ApiResponse<T> & Paginated;
function fetchUsers(page: number): PaginatedResponse<User[]> {
return {
data: [/* users */],
timestamp: Date.now(),
page,
pageSize: 20,
totalPages: 5
};
}
Intersection Types with Interfaces and Type Aliases
Both interfaces and type aliases work with intersection types, but they behave slightly differently.
Intersecting Interfaces
Interfaces can extend other interfaces, but you can also intersect them:
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Versioned {
version: number;
}
interface Auditable {
createdBy: string;
modifiedBy: string;
}
// Using intersection
type AuditedEntity = Timestamped & Versioned & Auditable;
// Equivalent to extending
interface AuditedEntityInterface extends Timestamped, Versioned, Auditable {}
The intersection approach is more flexible when you need to combine types conditionally or generically. Interface extension is cleaner for straightforward inheritance hierarchies.
Combining Type Aliases
Type aliases shine when mixing primitives, unions, and objects:
type ID = string | number;
type BaseEntity = {
id: ID;
};
type Named = {
name: string;
};
// Combining object types with unions
type NamedEntity = BaseEntity & Named;
const entity: NamedEntity = {
id: "123", // or id: 123
name: "Product"
};
Common Patterns and Best Practices
Composition Over Inheritance
Intersection types enable true composition, letting you build complex types from simple, reusable pieces:
type Identifiable = {
id: string;
};
type Activatable = {
active: boolean;
activate: () => void;
deactivate: () => void;
};
type Deletable = {
deleted: boolean;
delete: () => void;
};
// Compose features as needed
type User = Identifiable & Activatable & {
email: string;
};
type Product = Identifiable & Activatable & Deletable & {
name: string;
price: number;
};
This approach creates a library of reusable type building blocks. Each type represents a single capability or concern.
Building a Feature Flag System
Intersection types work brilliantly for feature flag systems where different parts of your app have different flags:
type CoreFeatures = {
newDashboard: boolean;
darkMode: boolean;
};
type BetaFeatures = {
aiAssistant: boolean;
advancedAnalytics: boolean;
};
type AdminFeatures = {
userManagement: boolean;
systemLogs: boolean;
};
type UserFeatureFlags = CoreFeatures & BetaFeatures;
type AdminFeatureFlags = CoreFeatures & BetaFeatures & AdminFeatures;
function checkFeature<T extends UserFeatureFlags>(
flags: T,
feature: keyof T
): boolean {
return flags[feature];
}
const adminFlags: AdminFeatureFlags = {
newDashboard: true,
darkMode: true,
aiAssistant: false,
advancedAnalytics: true,
userManagement: true,
systemLogs: true
};
if (checkFeature(adminFlags, 'systemLogs')) {
// Admin-only feature
}
Pitfalls and Conflict Resolution
Intersection types can produce unexpected results when properties conflict. Understanding how TypeScript resolves these conflicts prevents bugs.
Conflicting Property Types
When the same property appears in multiple types with incompatible types, the result is never:
type A = {
value: string;
};
type B = {
value: number;
};
type C = A & B;
// C.value is `never` because nothing can be both string AND number
const invalid: C = {
value: "hello" // Error: Type 'string' is not assignable to type 'never'
};
This happens because no value can simultaneously be a string and a number. The intersection is impossible, so TypeScript marks it as never.
Function Parameter Intersection
Functions intersect differently. When intersecting function types, parameters become unions (contravariance):
type HandlerA = (value: string) => void;
type HandlerB = (value: number) => void;
type CombinedHandler = HandlerA & HandlerB;
// This creates an overloaded function type
const handler: CombinedHandler = (value: string | number) => {
if (typeof value === 'string') {
console.log('String:', value);
} else {
console.log('Number:', value);
}
};
handler("hello"); // Valid
handler(42); // Valid
This behavior is useful for creating functions that handle multiple input types, effectively giving you function overloading through type composition.
Advanced Techniques
Generic Intersection Types
Generics with intersections create powerful, reusable type utilities:
type WithId<T> = T & { id: string };
type WithTimestamps<T> = T & {
createdAt: Date;
updatedAt: Date;
};
// Compose generic utilities
type Entity<T> = WithId<WithTimestamps<T>>;
type User = Entity<{
email: string;
name: string;
}>;
// User now has: id, createdAt, updatedAt, email, name
Merge Utility with Conflict Resolution
Create a utility type that merges two types, preferring the second type’s properties on conflicts:
type Merge<A, B> = {
[K in keyof A | keyof B]: K extends keyof B
? B[K]
: K extends keyof A
? A[K]
: never;
};
type Defaults = {
theme: 'light';
notifications: true;
language: 'en';
};
type UserPreferences = {
theme: 'dark';
fontSize: 14;
};
type FinalPreferences = Merge<Defaults, UserPreferences>;
// Result: { theme: 'dark', notifications: true, language: 'en', fontSize: 14 }
This pattern is invaluable for configuration merging, where user settings override defaults while preserving unspecified defaults.
Conditional Types with Intersections
Combine conditional types with intersections for sophisticated type transformations:
type AddReadonly<T, Condition extends boolean> = Condition extends true
? { readonly [K in keyof T]: T[K] }
: T;
type Immutable<T> = AddReadonly<T, true> & {
clone: () => T;
};
type Config = Immutable<{
apiUrl: string;
timeout: number;
}>;
// Config has readonly properties plus a clone method
Conclusion
Intersection types are a fundamental tool for type composition in TypeScript. They enable you to build complex types from simple, focused pieces, promoting reusability and maintainability. Use them to combine data from different domains, create mixins, and compose behaviors.
Remember that intersections require values to satisfy all constituent types. When property types conflict, you get never. When function types intersect, you get overload-like behavior. Master these mechanics, and you’ll write more expressive, type-safe TypeScript code.
Start small: identify repeated type patterns in your codebase and extract them into composable pieces. Build a library of small, focused types that you can combine as needed. Your future self will thank you for the flexibility and clarity.