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

Objective

Create a simple notification system with different notification types using union types.

Description

  1. Create a base notification interface with common properties (id, message)
  2. Create different notification types (info, success, error)
  3. Create a function that displays notifications differently based on their type

Expected Output

INFO: System update scheduled (ID: 1)
SUCCESS: Profile updated successfully (ID: 2)
ERROR: Login failed - Invalid credentials (ID: 3)

Starter Code

// Create your interfaces here

// Create your notification function here

// Test your implementation with these examples
const notifications = [
  // Create 3 different notification types here
];

// Display each notification
notifications.forEach((notification) => {
  displayNotification(notification);
});

Exercise 2: Form Field Component

Objective

Build a user profile system that supports different user types with different properties.

Description

  1. Create a base user type with common properties (id, name, email)
  2. Create specific types for different user roles (admin, regular, guest)
  3. Each role should have unique properties
  4. Create a function that formats user information based on their role

Expected Output

ADMIN: John Doe (ID: 1) - Access Level: Full
Email: john@example.com
Admin since: 01/15/2023

REGULAR: Jane Smith (ID: 2) - Subscription: Premium
Email: jane@example.com
Member since: 03/20/2023

GUEST: Guest User (ID: 3) - Session expires in: 24 hours
Email: guest@example.com

Starter Code

// Create your user type interfaces here

// Create the formatUserInfo function here

// Test your implementation with these examples
const users = [
  // Create one user of each type here
];

// Format and display each user's information
users.forEach((user) => {
  console.log(formatUserInfo(user));
  console.log("---");
});

Exercise 3: Result and Error Handling

Objective

Create a simplified form field system that handles different input types.

Description

  1. Define a base field type with common properties (name, label, required)
  2. Create specific field types for different inputs (text, number, checkbox)
  3. Use intersection types to combine common properties with type-specific ones
  4. Create a function that renders field descriptions based on their types

Expected Output

Text field: Username (required)
Placeholder: Enter your username
---
Number field: Age
Min: 18, Max: 100
---
Checkbox field: Accept terms (required)
Default value: not checked

Starter Code

// Create your base field type here

// Create specific field types using intersection types

// Create your union type for all field types

// Create the describeField function here

// Test with these examples
const fields = [
  // Create one field of each type here
];

// Display each field description
fields.forEach((field) => {
  console.log(describeField(field));
  console.log("---");
});

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.