TypeScript

Union & Intersection Types

28 min Lesson 7 of 40

Union & Intersection Types

TypeScript's type system becomes incredibly powerful when you combine types using union types and intersection types. These advanced type operators allow you to create flexible, expressive type definitions that accurately model your application's data structures.

Union Types

A union type describes a value that can be one of several types. You create union types using the vertical bar (|) operator. The value can be any of the types in the union.

Basic Union Types:

// Union of primitive types
let id: string | number;

id = 'abc123'; // Valid
id = 12345;     // Valid
// id = true;   // Error: Type 'boolean' is not assignable

// Union with literal types
type Status = 'pending' | 'approved' | 'rejected';

let orderStatus: Status;
orderStatus = 'pending';   // Valid
orderStatus = 'approved';  // Valid
// orderStatus = 'shipped'; // Error: Not in union

// Union of object types
type CreditCard = {
  type: 'credit';
  cardNumber: string;
  cvv: string;
};

type PayPal = {
  type: 'paypal';
  email: string;
};

type BankTransfer = {
  type: 'bank';
  accountNumber: string;
  routingNumber: string;
};

type PaymentMethod = CreditCard | PayPal | BankTransfer;

// Using union type
const payment1: PaymentMethod = {
  type: 'credit',
  cardNumber: '1234-5678-9012-3456',
  cvv: '123'
};

const payment2: PaymentMethod = {
  type: 'paypal',
  email: 'user@example.com'
};
Note: Union types are often referred to as "OR" types because a value can be type A OR type B OR type C. They're perfect for representing values that can take multiple forms.

Working with Union Types

When working with union types, TypeScript only allows you to access properties that are common to all types in the union:

Accessing Union Type Properties:

type Dog = {
  name: string;
  breed: string;
  bark(): void;
};

type Cat = {
  name: string;
  color: string;
  meow(): void;
};

type Pet = Dog | Cat;

function getPetName(pet: Pet): string {
  // ✓ Valid: 'name' exists on both Dog and Cat
  return pet.name;
}

function getPetInfo(pet: Pet): void {
  console.log(pet.name); // ✓ Valid

  // ✗ Error: Property 'breed' does not exist on type 'Pet'
  // console.log(pet.breed);

  // ✗ Error: Property 'bark' does not exist on type 'Pet'
  // pet.bark();
}

Type Narrowing

Type narrowing is the process of refining a union type to a more specific type. TypeScript uses control flow analysis to automatically narrow types based on your code logic.

Type Narrowing Techniques:

// 1. typeof narrowing
function formatValue(value: string | number): string {
  if (typeof value === 'string') {
    // TypeScript knows value is string here
    return value.toUpperCase();
  } else {
    // TypeScript knows value is number here
    return value.toFixed(2);
  }
}

// 2. Truthiness narrowing
function printLength(value: string | null | undefined): void {
  if (value) {
    // TypeScript knows value is string here
    console.log(value.length);
  } else {
    console.log('No value provided');
  }
}

// 3. Equality narrowing
function processInput(input: string | number, reference: string | number): void {
  if (input === reference) {
    // Both are same type here (but could be string or number)
    console.log(input.toString());
  }
}

// 4. in operator narrowing
type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird): void {
  if ('swim' in animal) {
    // TypeScript knows animal is Fish
    animal.swim();
  } else {
    // TypeScript knows animal is Bird
    animal.fly();
  }
}

// 5. instanceof narrowing
class Car {
  drive() {
    console.log('Driving...');
  }
}

class Boat {
  sail() {
    console.log('Sailing...');
  }
}

function operate(vehicle: Car | Boat): void {
  if (vehicle instanceof Car) {
    // TypeScript knows vehicle is Car
    vehicle.drive();
  } else {
    // TypeScript knows vehicle is Boat
    vehicle.sail();
  }
}
Tip: TypeScript's control flow analysis is smart enough to track type changes through complex conditional logic. Use these narrowing techniques to access type-specific properties safely.

Discriminated Unions

Discriminated unions (also called tagged unions) use a common property (the discriminant) to distinguish between different types in a union. This is one of the most powerful patterns in TypeScript.

Discriminated Union Pattern:

// Each type has a 'kind' discriminant
type Circle = {
  kind: 'circle';
  radius: number;
};

type Rectangle = {
  kind: 'rectangle';
  width: number;
  height: number;
};

type Triangle = {
  kind: 'triangle';
  base: number;
  height: number;
};

type Shape = Circle | Rectangle | Triangle;

// TypeScript narrows type based on discriminant
function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // TypeScript knows shape is Circle here
      return Math.PI * shape.radius ** 2;

    case 'rectangle':
      // TypeScript knows shape is Rectangle here
      return shape.width * shape.height;

    case 'triangle':
      // TypeScript knows shape is Triangle here
      return (shape.base * shape.height) / 2;

    default:
      // Exhaustiveness checking
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

// Usage
const circle: Shape = { kind: 'circle', radius: 5 };
const rect: Shape = { kind: 'rectangle', width: 10, height: 20 };
const triangle: Shape = { kind: 'triangle', base: 8, height: 6 };

console.log(calculateArea(circle));   // 78.54
console.log(calculateArea(rect));     // 200
console.log(calculateArea(triangle)); // 24
Note: The discriminant property (like kind or type) should have literal types. This allows TypeScript to narrow the union type in switch statements and if conditions.

Real-World Discriminated Union Example

API Response Handling:

// Define discriminated union for API responses
type SuccessResponse = {
  status: 'success';
  data: {
    id: string;
    name: string;
    email: string;
  };
  timestamp: Date;
};

type ErrorResponse = {
  status: 'error';
  error: {
    code: string;
    message: string;
  };
  timestamp: Date;
};

type LoadingResponse = {
  status: 'loading';
  progress: number;
};

type ApiResponse = SuccessResponse | ErrorResponse | LoadingResponse;

// Handle response based on status
function handleResponse(response: ApiResponse): void {
  switch (response.status) {
    case 'success':
      // TypeScript knows response is SuccessResponse
      console.log('User:', response.data.name);
      console.log('Email:', response.data.email);
      break;

    case 'error':
      // TypeScript knows response is ErrorResponse
      console.error('Error:', response.error.message);
      console.error('Code:', response.error.code);
      break;

    case 'loading':
      // TypeScript knows response is LoadingResponse
      console.log('Loading:', response.progress + '%');
      break;
  }
}

// Usage
const successResp: ApiResponse = {
  status: 'success',
  data: { id: '123', name: 'John', email: 'john@example.com' },
  timestamp: new Date()
};

const errorResp: ApiResponse = {
  status: 'error',
  error: { code: 'AUTH_FAILED', message: 'Invalid credentials' },
  timestamp: new Date()
};

handleResponse(successResp);
handleResponse(errorResp);

Intersection Types

Intersection types combine multiple types into one using the ampersand (&) operator. The resulting type has all properties from all combined types.

Basic Intersection Types:

// Individual types
type HasId = {
  id: string;
};

type HasTimestamps = {
  createdAt: Date;
  updatedAt: Date;
};

type HasAuthor = {
  author: string;
};

// Intersection type combining all three
type BlogPost = HasId & HasTimestamps & HasAuthor & {
  title: string;
  content: string;
  tags: string[];
};

// Usage - must have ALL properties
const post: BlogPost = {
  id: 'post-001',
  createdAt: new Date('2024-01-15'),
  updatedAt: new Date('2024-02-14'),
  author: 'Jane Doe',
  title: 'TypeScript Guide',
  content: 'Learn TypeScript...',
  tags: ['typescript', 'programming']
};
Note: Intersection types are often referred to as "AND" types because a value must satisfy type A AND type B AND type C. They're perfect for composing types from multiple sources.

Practical Intersection Type Examples

Mixins and Composition:

// Base types
type Serializable = {
  serialize(): string;
};

type Validatable = {
  validate(): boolean;
  errors: string[];
};

type Saveable = {
  save(): Promise<void>;
};

// Composed type using intersections
type FormField = Serializable & Validatable & Saveable & {
  name: string;
  value: unknown;
};

// Implementation
class TextField implements FormField {
  name: string;
  value: string;
  errors: string[] = [];

  constructor(name: string, value: string) {
    this.name = name;
    this.value = value;
  }

  serialize(): string {
    return JSON.stringify({ name: this.name, value: this.value });
  }

  validate(): boolean {
    this.errors = [];
    if (!this.value || this.value.trim() === '') {
      this.errors.push('Field cannot be empty');
      return false;
    }
    return true;
  }

  async save(): Promise<void> {
    if (this.validate()) {
      console.log('Saving:', this.serialize());
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1000));
    }
  }
}

// Usage
const emailField = new TextField('email', 'user@example.com');
emailField.save();

Combining Unions and Intersections

You can combine union and intersection types to create sophisticated type definitions:

Complex Type Combinations:

// Base types
type HasName = {
  name: string;
};

type HasAge = {
  age: number;
};

type HasEmail = {
  email: string;
};

// Union of intersections
type PersonalInfo = (HasName & HasAge) | (HasName & HasEmail);

// Valid: has name and age
const person1: PersonalInfo = {
  name: 'John',
  age: 30
};

// Valid: has name and email
const person2: PersonalInfo = {
  name: 'Jane',
  email: 'jane@example.com'
};

// Valid: has all three
const person3: PersonalInfo = {
  name: 'Bob',
  age: 25,
  email: 'bob@example.com'
};

// Invalid: only has name
// const person4: PersonalInfo = {
//   name: 'Alice'
// };

// Intersection of unions
type StringOrNumber = string | number;
type NumberOrBoolean = number | boolean;

// Result is number (the common type)
type OnlyNumber = StringOrNumber & NumberOrBoolean;

const value: OnlyNumber = 42; // Must be number
// const value2: OnlyNumber = 'test'; // Error
// const value3: OnlyNumber = true; // Error
Warning: When intersecting types with conflicting properties, TypeScript creates a never type. For example, { x: string } & { x: number } results in never because no value can be both string and number.

Advanced Union Patterns

Optional Union Members:

// Making union members optional
type Config = {
  theme: 'light' | 'dark';
  language: string;
  notifications?: {
    email: boolean;
    push: boolean;
  };
  advanced?: {
    debugMode: boolean;
    logLevel: 'info' | 'warn' | 'error';
  };
};

const basicConfig: Config = {
  theme: 'dark',
  language: 'en'
};

const advancedConfig: Config = {
  theme: 'light',
  language: 'en',
  notifications: {
    email: true,
    push: false
  },
  advanced: {
    debugMode: true,
    logLevel: 'info'
  }
};

// Function handling optional unions
function applyConfig(config: Config): void {
  console.log('Theme:', config.theme);

  if (config.notifications) {
    console.log('Email notifications:', config.notifications.email);
  }

  if (config.advanced) {
    console.log('Debug mode:', config.advanced.debugMode);
  }
}

Type Guards for Complex Unions

Create custom type guard functions for complex union type checking:

Custom Type Guards:

// Define union types
type Admin = {
  role: 'admin';
  permissions: string[];
  accessLevel: number;
};

type User = {
  role: 'user';
  subscriptionTier: 'free' | 'premium';
};

type Guest = {
  role: 'guest';
  sessionId: string;
};

type Account = Admin | User | Guest;

// Type guard functions
function isAdmin(account: Account): account is Admin {
  return account.role === 'admin';
}

function isUser(account: Account): account is User {
  return account.role === 'user';
}

function isGuest(account: Account): account is Guest {
  return account.role === 'guest';
}

// Usage with type guards
function getAccountInfo(account: Account): string {
  if (isAdmin(account)) {
    // TypeScript knows account is Admin
    return `Admin with ${account.permissions.length} permissions`;
  }

  if (isUser(account)) {
    // TypeScript knows account is User
    return `${account.subscriptionTier} user`;
  }

  if (isGuest(account)) {
    // TypeScript knows account is Guest
    return `Guest session: ${account.sessionId}`;
  }

  // Exhaustiveness check
  const _exhaustive: never = account;
  return _exhaustive;
}

// Test
const admin: Account = {
  role: 'admin',
  permissions: ['read', 'write', 'delete'],
  accessLevel: 10
};

const user: Account = {
  role: 'user',
  subscriptionTier: 'premium'
};

console.log(getAccountInfo(admin)); // "Admin with 3 permissions"
console.log(getAccountInfo(user));  // "premium user"
Exercise:
  1. Create a discriminated union Vehicle with three types: Car (kind: 'car', doors: number, fuelType: string), Motorcycle (kind: 'motorcycle', engineSize: number), and Bicycle (kind: 'bicycle', gearCount: number)
  2. Create a function describeVehicle that takes a Vehicle and returns a description string based on the discriminant
  3. Create intersection type ElectricVehicle that combines a base Vehicle with { batteryCapacity: number, range: number }
  4. Create a type guard function isCar that checks if a vehicle is a car
  5. Test your types and functions with sample data

Summary

  • Union types use | to create types that can be one of several types (OR logic)
  • Type narrowing refines union types using typeof, instanceof, in, truthiness, and equality checks
  • Discriminated unions use a common property (discriminant) to distinguish between types in a union
  • Intersection types use & to combine multiple types into one (AND logic)
  • Union types are perfect for values that can take multiple forms or states
  • Intersection types are perfect for composing types from multiple sources or mixins
  • Type guards help safely narrow union types and access type-specific properties
  • Combining unions and intersections creates powerful, flexible type definitions