TypeScript Union and Intersection Types

TypeScript's type system provides powerful tools for modeling complex data structures. Among these, union and intersection types are essential features that allow you to combine types in different ways. Understanding these concepts will significantly enhance your ability to write type-safe, flexible code.

Union Types: Combining Possibilities

Union types allow you to express that a value can be one of several types. They are created using the pipe (|) symbol.

Basic union type

// A variable that can be either a string or a number
let id: string | number;

id = 101; // Valid
id = "A201"; // Valid
// id = true;  // Error: Type 'boolean' is not assignable to type 'string | number'

In this example, the variable id can hold either a string or a number, but not any other type such as boolean or object.

Working with Union Types

When working with union types, TypeScript will only allow operations that are valid for all possible types in the union:

Union type operations

function printId(id: string | number) {
  console.log(`ID: ${id}`);

  // Error: Property 'toUpperCase' does not exist on type 'string | number'.
  // Property 'toUpperCase' does not exist on type 'number'.
  // console.log(id.toUpperCase());
}

To perform operations specific to one type, you need to narrow the type using type guards:

Type narrowing with union types

function printId(id: string | number) {
  console.log(`ID: ${id}`);

  // Type narrowing with typeof
  if (typeof id === "string") {
    // In this block, TypeScript knows id is a string
    console.log(id.toUpperCase());
  } else {
    // In this block, TypeScript knows id is a number
    console.log(id.toFixed(2));
  }
}

Union Types with Arrays

You can create unions of array types or arrays that can contain multiple types:

Union types with arrays

// An array of strings OR an array of numbers
let data: string[] | number[];

data = ["apple", "banana", "cherry"]; // Valid
data = [1, 2, 3, 4, 5]; // Valid
// data = [1, "two", 3];               // Error: Type 'string' is not assignable to type 'number'

// An array that can contain both strings AND numbers
let mixedData: (string | number)[];

mixedData = [1, "two", 3, "four"]; // Valid
mixedData = ["one", "two", "three"]; // Valid
mixedData = [1, 2, 3]; // Valid
// mixedData = [true, 1, "three"];     // Error: Type 'boolean' is not assignable to type 'string | number'

Note the difference between string[] | number[] (either an array of strings OR an array of numbers) and (string | number)[] (an array that can contain BOTH strings and numbers).

Discriminated Unions

Discriminated unions are a pattern where you use a common property (the "discriminant") to differentiate between union members. This makes it easier to work with complex union types:

Discriminated unions

// Define interfaces with a common "kind" property
interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

// Create a union type
type Shape = Circle | Square | Triangle;

// Function that uses the discriminant to handle each shape
function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // TypeScript's exhaustiveness checking helps ensure all cases are handled
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

// Usage examples
const myCircle: Circle = { kind: "circle", radius: 5 };
console.log(calculateArea(myCircle)); // 78.54...

const mySquare: Square = { kind: "square", sideLength: 4 };
console.log(calculateArea(mySquare)); // 16

The never type in the default case helps with exhaustiveness checking. If you later add a new shape to the union but forget to handle it in the function, TypeScript will give you a compile-time error.

Practical Example: API Response Handling

Union types are excellent for handling different response types from APIs:

API Response handling

// Define different response types
interface SuccessResponse {
  status: "success";
  data: {
    id: number;
    name: string;
    // other properties...
  };
}

interface ErrorResponse {
  status: "error";
  error: {
    code: number;
    message: string;
  };
}

interface LoadingResponse {
  status: "loading";
}

// Combine into a union type
type ApiResponse = SuccessResponse | ErrorResponse | LoadingResponse;

// Function to handle different response types
function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case "success":
      console.log(`Data received: ${response.data.name}`);
      return response.data;
    case "error":
      console.error(`Error ${response.error.code}: ${response.error.message}`);
      throw new Error(response.error.message);
    case "loading":
      console.log("Data is loading...");
      return null;
  }
}

// Example usage
const successResponse: SuccessResponse = {
  status: "success",
  data: {
    id: 123,
    name: "User One",
  },
};

handleResponse(successResponse); // "Data received: User One"

Intersection Types: Combining Multiple Types

Intersection types allow you to combine multiple types into one. This is done using the ampersand (&) symbol. An intersection type contains all features from all the constituent types.

Basic intersection type

// Define two separate types
type Person = {
  name: string;
  age: number;
};

type Employee = {
  companyId: string;
  role: string;
};

// Combine them with an intersection
type EmployeeWithPersonalInfo = Person & Employee;

// The resulting type has all properties from both types
const employee: EmployeeWithPersonalInfo = {
  name: "John Smith",
  age: 32,
  companyId: "E123",
  role: "Developer",
};

The EmployeeWithPersonalInfo type contains all properties from both Person and Employee.

Use Cases for Intersection Types

Intersection types are particularly useful when you want to:

  1. Extend or add capabilities to existing types:

Extending types

// Base configuration type
type BaseConfig = {
  endpoint: string;
  timeout: number;
};

// Authentication configuration
type AuthConfig = {
  apiKey: string;
  username: string;
};

// Create a complete configuration that has all properties
type FullConfig = BaseConfig & AuthConfig;

function initializeApp(config: FullConfig) {
  // Has access to all properties from both types
  console.log(`Connecting to ${config.endpoint} with key ${config.apiKey}`);
  // ...
}
  1. Implement mixins or traits:

Trait/mixin implementation

// Define trait-like types
type Loggable = {
  log: (message: string) => void;
};

type Serializable = {
  serialize: () => string;
};

type Persistable = {
  save: () => void;
  load: () => void;
};

// Create a class that implements multiple traits
class ConfigurationManager implements Loggable & Serializable & Persistable {
  private data: Record<string, any> = {};

  constructor(initialData: Record<string, any> = {}) {
    this.data = initialData;
  }

  // Implement Loggable
  log(message: string): void {
    console.log(`[ConfigManager] ${message}`);
  }

  // Implement Serializable
  serialize(): string {
    return JSON.stringify(this.data);
  }

  // Implement Persistable
  save(): void {
    localStorage.setItem('config', this.serialize());
    this.log('Configuration saved');
  }

  load(): void {
    const stored = localStorage.getItem('config');
    if (stored) {
      this.data = JSON.parse(stored);
      this.log('Configuration loaded');
    }
  }

  // Additional methods
  set(key: string, value: any): void {
    this.data[key] = value;
  }

  get(key: string): any {
    return this.data[key];
  }
}

Intersection Types with Incompatible Properties

When creating intersection types, be careful with properties that share the same name but have different types. If the types are not compatible, the property type becomes never:

Incompatible properties

type TypeA = {
  x: number;
  y: string;
};

type TypeB = {
  x: string;  // Note: x is a string here, but a number in TypeA
  z: boolean;
};

// The intersection will have properties x, y, and z
// But the type of x is the intersection of number & string, which is never
type TypeC = TypeA & TypeB;

// This is impossible to create because x can't be both a number and a string
const instance: TypeC = {
  x: /* ??? */, // Error: Type 'number' is not assignable to type 'never'
  y: "hello",
  z: true
};

In practice, you should avoid creating intersection types with incompatible properties.

Combining Union and Intersection Types

Union and intersection types can be combined to create sophisticated type structures:

Combining union and intersection

// Product-related types
type Product = {
  id: string;
  name: string;
  price: number;
};

type PhysicalProduct = Product & {
  weight: number;
  dimensions: {
    width: number;
    height: number;
    depth: number;
  };
};

type DigitalProduct = Product & {
  downloadUrl: string;
  sizeInMb: number;
};

// OrderItem can be either a physical or digital product with quantity
type OrderItem = {
  quantity: number;
} & (PhysicalProduct | DigitalProduct);

// Cart can contain multiple order items
type Cart = {
  items: OrderItem[];
  calculateTotal: () => number;
};

// Implementation example
function processOrderItem(item: OrderItem) {
  console.log(`Processing ${item.quantity}x ${item.name}`);

  // Use in operator to distinguish between product types
  if ("weight" in item) {
    console.log(`Physical product weighing ${item.weight}kg`);
  } else {
    console.log(`Digital product, download size: ${item.sizeInMb}MB`);
  }
}

Type Guards with Union and Intersection Types

When working with complex union or intersection types, type guards help you narrow down types to safely access type-specific properties.

Type guards with unions

type Admin = {
  role: "admin";
  permissions: string[];
};

type User = {
  role: "user";
  lastLogin: Date;
};

type Member = Admin | User;

// Using discriminated unions
function displayMemberInfo(member: Member) {
  console.log(`Role: ${member.role}`);

  if (member.role === "admin") {
    console.log(`Permissions: ${member.permissions.join(", ")}`);
  } else {
    console.log(`Last Login: ${member.lastLogin.toLocaleString()}`);
  }
}

// Using custom type guards with type predicates
function isAdmin(member: Member): member is Admin {
  return member.role === "admin";
}

function isUser(member: Member): member is User {
  return member.role === "user";
}

function displayMemberInfoAlternative(member: Member) {
  if (isAdmin(member)) {
    console.log(`Admin with permissions: ${member.permissions.join(", ")}`);
  } else if (isUser(member)) {
    console.log(`User last logged in on: ${member.lastLogin.toLocaleString()}`);
  }
}

Optional Properties and Nullability with Union Types

Union types are often used with null or undefined to represent optional or potentially missing values:

Nullable unions

// Nullable types
type Response = {
  data: string | null; // Can be string or null
  error?: string; // Optional property (string | undefined)
};

function processResponse(response: Response) {
  // Check if data exists
  if (response.data) {
    console.log(`Data: ${response.data}`);
  } else {
    console.log("No data received");
  }

  // Check if error exists
  if (response.error) {
    console.error(`Error: ${response.error}`);
  }
}

// Examples
processResponse({ data: "Success" });
processResponse({ data: null, error: "Failed to load data" });

Best Practices for Union and Intersection Types

When to Use Union Types

  • When a value can be one of several distinct types
  • For function parameters that accept multiple types
  • For representing error/success states (like API responses)
  • For nullable types (string | null)
  • When implementing state machines where values can be in different states

When to Use Intersection Types

  • To combine multiple types into a single type with all properties
  • For mixins or adding capabilities to existing types
  • To implement interfaces with multiple concerns
  • For extending configuration objects
  • When creating reusable component props in React

General Guidelines

  1. Use discriminated unions when working with complex union types to make type narrowing more reliable.
  2. Avoid creating intersections with conflicting property types which result in never types.
  3. Use union types for state management:

State management with unions

type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success", data: string }
  | { status: "error", error: Error };

function renderState(state: State) {
  switch (state.status) {
    case "idle":
      return <div>Please click start</div>;
    case "loading":
      return <div>Loading...</div>;
    case "success":
      return <div>Data: {state.data}</div>;
    case "error":
      return <div>Error: {state.error.message}</div>;
  }
}
  1. Create utility functions or type guards for complex type testing.
  2. Consider alternatives to very complex types:
    • For extremely complex types, consider breaking them down into smaller, more manageable pieces
    • Sometimes classes with inheritance might be more appropriate than complex type combinations

Exercises

Exercise 1: Notification System

Create a notification system that can handle different types of notifications using union types:

  • Create a base notification interface with common properties (id, title,timestamp)
  • Create extended interfaces for different notification types (info, success, error, warning)
  • Each notification type should have specific properties unique to that type
  • Create a union type that combines all notification types
  • Implement a function that displays notifications differently based on their type
  • Test your system with several different notification examples

Exercise 2: Form Field Component

Create a reusable form field component that can handle different input types using intersection and union types:

  • Define a base field type with common properties (name, label, required, disabled)
  • Create additional types for validation rules and event handlers
  • Define specific field types for different inputs (text, number, checkbox, select)
  • Use intersection types to combine common field properties with type-specific properties
  • Create a union type that represents any possible form field
  • Implement a function that renders different field types based on their properties
  • Test your implementation with a variety of field configurations

Exercise 3: Result and Error Handling

Create a Result type using union types to handle operations that might succeed or fail:

  • Define a discriminated union type that can represent either success or failure
  • For success cases, include the actual result value of generic type T
  • For failure cases, include an error message and optional error code
  • Create helper functions to construct success and failure results
  • Implement a function that safely parses JSON using the Result type
  • Create a generic handler function that processes results differently for success and failure
  • Demonstrate your Result type with examples of successful and failed operations
  • Show how to chain multiple operations that could each fail

Summary

TypeScript's union and intersection types are powerful tools for creating flexible, type-safe code:

  • Union types (A | B) allow a value to be one of several types, providing flexibility while maintaining type safety
  • Intersection types (A & B) combine multiple types into one, creating a new type with all the features of the constituent types
  • Discriminated unions use a common property to distinguish between different members of a union type
  • Type guards help narrow down types when working with unions
  • Combining unions and intersections enables modeling of complex data structures with precise type checking

Understanding when and how to use these type combinations is crucial for writing expressive, maintainable TypeScript code. Union types are ideal for representing values that could be of different types, while intersection types excel at combining features from multiple types into a single entity.

As you continue to explore TypeScript, you'll find these type constructs indispensable for modeling complex domains and ensuring your code behaves as expected.