TypeScript Union Types vs Intersection Types: How the Compiler Handles Type Composition

TypeScript union types (A | B) allow a value to be one of several types, while intersection types (A & B) require a value to satisfy all types simultaneously.

In the microsoft/TypeScript compiler, these two type composition mechanisms form the backbone of flexible type systems. Union types represent "either/or" scenarios, while intersection types create "and" relationships that merge type capabilities. Both are implemented as distinct interfaces extending a common base class within the compiler's type system.

What Are TypeScript Union Types?

A union type describes a value that can be one of several specified types. The compiler represents this internally using the UnionType interface, which stores the constituent types in a types array.

In src/compiler/types.ts at approximately line 6745, the UnionType interface extends UnionOrIntersectionType:

interface UnionType extends UnionOrIntersectionType {
    // Union-specific properties
}

The compiler creates union types through the getUnionType method in src/compiler/checker.ts, which performs type reduction and normalization to eliminate redundant constituents.

Union Type Example

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number };

function area(s: Shape): number {
  // Type narrowing based on discriminant property
  if (s.kind === "circle") {
    return Math.PI * s.radius ** 2;  // s is narrowed to circle
  }
  return s.sideLength ** 2;            // s is narrowed to square
}

What Are TypeScript Intersection Types?

An intersection type combines multiple types into one, requiring a value to satisfy all constituent types simultaneously. The compiler implements this via the IntersectionType interface at approximately line 6756 in src/compiler/types.ts.

Like unions, intersections extend UnionOrIntersectionType and store member types in a types array, but the semantic checking requires values to match every constituent rather than any single one.

The getIntersectionType function in src/compiler/checker.ts handles the creation of these types, performing normalization to resolve overlapping properties and handle conflicting type constraints.

Intersection Type Example

type Printable = { print(): void };
type Serializable = { serialize(): string };

type PrintableSerializable = Printable & Serializable;

class Document implements PrintableSerializable {
  print() {
    console.log("Printing document...");
  }
  
  serialize() {
    return JSON.stringify(this);
  }
}

Key Differences Between Union and Intersection Types

The fundamental distinction lies in the logical operators they represent: union types use OR logic (any one type), while intersection types use AND logic (all types).

Aspect Union Types (A | B) Intersection Types (A & B)
Value Requirement Must satisfy at least one constituent type Must satisfy all constituent types simultaneously
Internal Interface UnionType in src/compiler/types.ts IntersectionType in src/compiler/types.ts
Creation Method checker.getUnionType(types) checker.getIntersectionType(types)
Type Narrowing Supported via control-flow analysis and type guards Not applicable (already requires all properties)
Common Use Cases Function overloads, discriminated unions, optional values Mixins, object composition, merging interfaces

How the TypeScript Compiler Implements These Types

Core Type Definitions in src/compiler/types.ts

Both union and intersection types share a common base interface, UnionOrIntersectionType, defined in the compiler's type system definitions. This base contains the types: Type[] property that holds the constituent type references.

The specific interfaces are located at:

  • Line ~6745: UnionType interface
  • Line ~6756: IntersectionType interface

Additionally, the AST node representations are defined as:

  • Line ~2300: UnionTypeNode
  • Line ~2305: IntersectionTypeNode

Type Checker APIs

The TypeChecker class in src/compiler/checker.ts exposes the primary methods for constructing these types:

  • getUnionType(types: Type[], reduction?: TypeFlags): UnionType
  • getIntersectionType(types: Type[], reduction?: TypeFlags): IntersectionType

These methods handle type normalization, eliminating duplicate types in unions and resolving property conflicts in intersections.

Type Guard Utilities

For compiler services and language features, type guards are exported from src/services/types.ts at approximately line 126:

function isUnion(): this is UnionType;
function isIntersection(): this is IntersectionType;

These allow the compiler to quickly discriminate between union and intersection representations during type analysis and code generation.

Practical Examples

Discriminated Unions with Union Types

Discriminated unions represent one of the most powerful patterns enabled by union types, allowing exhaustive type checking:

type NetworkState =
  | { state: "loading" }
  | { state: "failed"; code: number }
  | { state: "success"; response: string };

function handleState(state: NetworkState) {
  switch (state.state) {
    case "loading":
      return "Loading...";
    case "failed":
      return `Error ${state.code}`;  // TypeScript knows code exists here
    case "success":
      return state.response;          // TypeScript knows response exists here
  }
}

Mixin Patterns with Intersection Types

Intersection types enable the mixin pattern, combining multiple class capabilities:

type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    timestamp = Date.now();
  };
}

function Activatable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    isActive = true;
    toggle() {
      this.isActive = !this.isActive;
    }
  };
}

// Creating an intersection of capabilities
const TimestampedActivatableUser = Timestamped(Activatable(class {
  name = "user";
}));

const instance = new TimestampedActivatableUser();
// instance has name, isActive, toggle(), and timestamp

Using the Compiler API Internally

When working with the TypeScript compiler API directly, you manipulate these types programmatically:

import * as ts from "typescript";

const program = ts.createProgram(["file.ts"], {});
const checker = program.getTypeChecker();

// Creating types programmatically (simplified example)
const stringType = checker.getStringType();
const numberType = checker.getNumberType();

// Union: string | number
const unionType = checker.getUnionType([stringType, numberType]);

// Intersection: string & number (effectively never in practice)
const intersectionType = checker.getIntersectionType([stringType, numberType]);

Summary

  • TypeScript union types (A | B) allow values to be one of several types, implemented internally as the UnionType interface in src/compiler/types.ts at line ~6745.
  • TypeScript intersection types (A & B) require values to satisfy all constituent types simultaneously, implemented as the IntersectionType interface at line ~6756.
  • The compiler creates these types via getUnionType() and getIntersectionType() in src/compiler/checker.ts, with type guards isUnion() and isIntersection() available in src/services/types.ts.
  • Union types enable discriminated unions and type narrowing, while intersection types support mixins and object composition patterns.

Frequently Asked Questions

When should I use union types versus intersection types?

Use union types when a value can be one of several distinct alternatives, such as different states in a state machine ("loading" | "success" | "error") or different shapes of data. Use intersection types when you need to combine multiple type capabilities into one, such as merging interfaces (Serializable & Printable) or creating mixins where a class must implement multiple behaviors simultaneously.

Can a type be both a union and an intersection?

Yes, types can nest these constructs arbitrarily. You can have a union of intersections ((A & B) | (C & D)) or an intersection containing unions ((A | B) & (C | D)). The TypeScript compiler handles these recursively using the same UnionType and IntersectionType internal representations, flattening and normalizing them according to the type system's rules.

How does TypeScript handle empty unions or intersections?

An empty union (never in TypeScript terminology) represents a type with no possible values, while an empty intersection effectively reduces to the universal type unknown or {} depending on context. The compiler's getUnionType and getIntersectionType methods in src/compiler/checker.ts handle these edge cases by returning the appropriate bottom or top types, ensuring the type system remains sound even with degenerate cases.

Do union and intersection types affect JavaScript runtime performance?

No, union and intersection types are compile-time only constructs. They are erased during the TypeScript-to-JavaScript compilation process and leave no trace in the generated code. The runtime performance characteristics depend entirely on the underlying JavaScript values and operations, not on the TypeScript type annotations. The compiler uses these types solely for static analysis, type checking, and IntelliSense generation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s https://instagit.com/install.md

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client