Union & Intersection Types

Difficulty: Intermediate

Question

Explain union and intersection types. How do they differ and when would you use each?

Answer

Union types (A | B) represent values that can be ONE of several types. You can only access properties common to all members unless you narrow the type first.

Intersection types (A & B) combine multiple types into one that has ALL properties from every type. The result must satisfy every constituent type.

Key differences: - Union: value is A OR B (wider, more flexible) - Intersection: value is A AND B (narrower, more specific) - Union of objects: only shared properties are accessible - Intersection of objects: all properties from all types are accessible

Unions model alternatives. Intersections model composition/mixins.

Code examples

Union Types in Practice

// Basic union
type ID = string | number;

function findUser(id: ID): User | undefined {
  // Must narrow before using type-specific methods
  if (typeof id === 'string') {
    return users.find(u => u.slug === id);
  }
  return users.find(u => u.id === id);
}

// Union of object types
type SuccessResponse = {
  status: 'success';
  data: unknown;
};
type ErrorResponse = {
  status: 'error';
  error: string;
  code: number;
};
type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(res: ApiResponse) {
  if (res.status === 'success') {
    console.log(res.data);  // OK: narrowed to SuccessResponse
  } else {
    console.log(res.error); // OK: narrowed to ErrorResponse
    console.log(res.code);
  }
}

Unions with a discriminant field (like 'status') enable safe narrowing. Without narrowing, only shared properties are accessible.

Intersection Types for Composition

// Composing types with intersection
type Timestamped = {
  createdAt: Date;
  updatedAt: Date;
};

type SoftDeletable = {
  deletedAt: Date | null;
  isDeleted: boolean;
};

type Identifiable = {
  id: string;
};

// Combine into a full entity type
type BaseEntity = Identifiable & Timestamped & SoftDeletable;

interface User extends BaseEntity {
  name: string;
  email: string;
}

// Mixin function using intersection
function withLogging<T>(obj: T): T & { log: (msg: string) => void } {
  return {
    ...obj,
    log(msg: string) {
      console.log(`[${new Date().toISOString()}] ${msg}`);
    },
  };
}

const user = withLogging({ name: 'Alice', role: 'admin' });
user.name; // string
user.log('action performed'); // works

Intersections are ideal for composing reusable type fragments. Each fragment adds capabilities without modifying the original types.

Union vs Intersection Edge Cases

// Intersection of primitives = never
type Impossible = string & number; // never

// Intersection of unions = narrower
type A = 'a' | 'b' | 'c';
type B = 'b' | 'c' | 'd';
type Both = A & B; // 'b' | 'c' (only shared members)

// Union of intersections (common pattern)
type Event =
  | { type: 'click'; x: number; y: number }
  | { type: 'keypress'; key: string; code: number }
  | { type: 'scroll'; deltaX: number; deltaY: number };

function handleEvent(event: Event) {
  switch (event.type) {
    case 'click':
      console.log(event.x, event.y);        // narrowed
      break;
    case 'keypress':
      console.log(event.key, event.code);    // narrowed
      break;
    case 'scroll':
      console.log(event.deltaX, event.deltaY); // narrowed
      break;
  }
}

// Intersection with conflicting properties
type Left = { value: string };
type Right = { value: number };
type Conflict = Left & Right;
// value: string & number => never (impossible to satisfy)

Intersecting primitive types or conflicting property types produces 'never'. Intersecting unions keeps only the shared members.

Key points

Concepts covered

Union Types, Intersection Types, Type Narrowing, Distributive Unions, Combining Types