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 never when 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.