Learn

/

Union & Intersection

Union & Intersection

7 patterns

Union types for alternatives, intersection types for combining, and the `never` type for exhaustive checks. You'll hit this when a value can be one of several shapes and you need to handle all of them.

Avoid
// Overly broad type
function setStatus(status: string) {
  // Any string is accepted
}

setStatus("active");  // OK
setStatus("acitve");  // No error, typo undetected
setStatus("banana");  // No error, nonsense value
// Overly broad type
function setStatus(status: string) {
  // Any string is accepted
}

setStatus("active");  // OK
setStatus("acitve");  // No error, typo undetected
setStatus("banana");  // No error, nonsense value

Prefer
type Status = "active" | "inactive" | "pending";

function setStatus(status: Status) {
  // Only valid values accepted
}

setStatus("active");  // OK
setStatus("acitve");  // Error: typo caught
setStatus("banana");  // Error: not a valid Status
type Status = "active" | "inactive" | "pending";

function setStatus(status: Status) {
  // Only valid values accepted
}

setStatus("active");  // OK
setStatus("acitve");  // Error: typo caught
setStatus("banana");  // Error: not a valid Status
Why avoid

Using string accepts any string, so typos and nonsense values compile without errors. The bug only surfaces at runtime when the code does not recognize the value. Literal unions catch these mistakes during development.

Why prefer

A string literal union limits values to an exact set of allowed strings. TypeScript catches typos and invalid values at compile time. You also get autocomplete when typing the argument, which makes the API self-documenting.

TypeScript Handbook: Union types
Avoid
type Result = {
  success: boolean;
  data?: string;
  error?: string;
};

function handle(result: Result) {
  if (result.success) {
    // data might still be undefined
    console.log(result.data.toUpperCase());
  }
}
type Result = {
  success: boolean;
  data?: string;
  error?: string;
};

function handle(result: Result) {
  if (result.success) {
    // data might still be undefined
    console.log(result.data.toUpperCase());
  }
}

Prefer
type Result =
  | { status: "ok"; data: string }
  | { status: "error"; error: string };

function handle(result: Result) {
  switch (result.status) {
    case "ok":
      console.log(result.data.toUpperCase()); // safe
      break;
    case "error":
      console.log(result.error); // safe
      break;
  }
}
type Result =
  | { status: "ok"; data: string }
  | { status: "error"; error: string };

function handle(result: Result) {
  switch (result.status) {
    case "ok":
      console.log(result.data.toUpperCase()); // safe
      break;
    case "error":
      console.log(result.error); // safe
      break;
  }
}
Why avoid

A boolean success field with optional data and error does not guarantee that data exists when success is true. TypeScript cannot narrow based on a boolean flag linked to optional properties. You can still get undefined at runtime.

Why prefer

A discriminated union with a status field guarantees that data exists when status is "ok" and error exists when status is "error". Each branch has exactly the properties it needs, with no optional fields to check.

TypeScript Handbook: Discriminated unions
Avoid
type Shape = "circle" | "square" | "triangle";

function area(shape: Shape) {
  switch (shape) {
    case "circle":
      return Math.PI * 10 ** 2;
    case "square":
      return 10 * 10;
    // Forgot "triangle"!
    // No compile error, returns undefined
  }
}
type Shape = "circle" | "square" | "triangle";

function area(shape: Shape) {
  switch (shape) {
    case "circle":
      return Math.PI * 10 ** 2;
    case "square":
      return 10 * 10;
    // Forgot "triangle"!
    // No compile error, returns undefined
  }
}

Prefer
type Shape = "circle" | "square" | "triangle";

function area(shape: Shape) {
  switch (shape) {
    case "circle":
      return Math.PI * 10 ** 2;
    case "square":
      return 10 * 10;
    case "triangle":
      return (10 * 8.66) / 2;
    default: {
      const _exhaustive: never = shape;
      return _exhaustive;
    }
  }
}
type Shape = "circle" | "square" | "triangle";

function area(shape: Shape) {
  switch (shape) {
    case "circle":
      return Math.PI * 10 ** 2;
    case "square":
      return 10 * 10;
    case "triangle":
      return (10 * 8.66) / 2;
    default: {
      const _exhaustive: never = shape;
      return _exhaustive;
    }
  }
}
Why avoid

Without an exhaustive check, forgetting a case silently returns undefined. Worse, when a new variant is added to the union later, there is no compiler error to remind you to handle it. Bugs surface at runtime instead of at build time.

Why prefer

Assigning shape to a never variable in the default case fails at compile time if any union member is unhandled. When you add a new variant to Shape, every switch statement with this pattern produces an error until you handle it. This is called an exhaustive check.

TypeScript Handbook: Exhaustiveness checking
Avoid
type Admin = { role: "admin"; permissions: string[] };
type Guest = { role: "guest" };

type User = Admin | Guest;

function showPermissions(user: User) {
  // Error: permissions doesn't exist on Guest
  return user.permissions.join(", ");
}
type Admin = { role: "admin"; permissions: string[] };
type Guest = { role: "guest" };

type User = Admin | Guest;

function showPermissions(user: User) {
  // Error: permissions doesn't exist on Guest
  return user.permissions.join(", ");
}

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

type User = Admin | Guest;

function showPermissions(user: User) {
  if (user.role === "admin") {
    return user.permissions.join(", ");
  }
  return "No permissions (guest)";
}
type Admin = { role: "admin"; permissions: string[] };
type Guest = { role: "guest" };

type User = Admin | Guest;

function showPermissions(user: User) {
  if (user.role === "admin") {
    return user.permissions.join(", ");
  }
  return "No permissions (guest)";
}
Why avoid

Without narrowing, TypeScript sees the full union and only allows access to properties common to all variants. Since Guest does not have permissions, direct access is a compile error. You must check the discriminant first.

Why prefer

Checking user.role === "admin" narrows the type to Admin, making permissions safely accessible. The discriminant field role tells TypeScript exactly which variant you are working with. The else branch knows the user is a Guest.

TypeScript Handbook: Narrowing
Avoid
// One giant interface for everything
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
  lastLogin: Date;
  preferences: Record<string, string>;
}
// One giant interface for everything
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
  lastLogin: Date;
  preferences: Record<string, string>;
}

Prefer
interface Identity {
  id: string;
  name: string;
  email: string;
}

interface Timestamps {
  createdAt: Date;
  updatedAt: Date;
}

interface Trackable {
  lastLogin: Date;
  preferences: Record<string, string>;
}

type User = Identity & Timestamps & Trackable;
interface Identity {
  id: string;
  name: string;
  email: string;
}

interface Timestamps {
  createdAt: Date;
  updatedAt: Date;
}

interface Trackable {
  lastLogin: Date;
  preferences: Record<string, string>;
}

type User = Identity & Timestamps & Trackable;
Why avoid

A monolithic interface mixes unrelated concerns. If another entity needs timestamps, you either duplicate the fields or create an awkward inheritance chain. Composing small interfaces with intersections keeps things modular and reusable.

Why prefer

Intersection types (&) combine multiple interfaces into one. Each piece is reusable on its own: Timestamps can be used for posts, comments, or any entity. The resulting User type has all properties from all three interfaces.

TypeScript Handbook: Intersection types
Avoid
enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

// Enums create runtime objects
// Enums are nominal: "UP" !== Direction.Up in some cases
// Enums add JS output even when only used as types
function move(dir: Direction) { }
move(Direction.Up);
enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

// Enums create runtime objects
// Enums are nominal: "UP" !== Direction.Up in some cases
// Enums add JS output even when only used as types
function move(dir: Direction) { }
move(Direction.Up);

Prefer
type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT";

// Zero runtime cost, just types
// Plain strings work directly
// JSON payloads match without conversion
function move(dir: Direction) { }
move("UP");
type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT";

// Zero runtime cost, just types
// Plain strings work directly
// JSON payloads match without conversion
function move(dir: Direction) { }
move("UP");
Why avoid

String enums generate a runtime JavaScript object, increasing bundle size. They also require importing the enum to use its values, which can be awkward with JSON data from APIs. Unions are lighter and more interoperable for string-based value sets.

Why prefer

String literal unions are erased at compile time, adding zero bytes to the bundle. They work naturally with JSON (no need to convert strings to enum values), and they provide the same autocomplete and type checking as enums. For most use cases, unions are simpler.

Total TypeScript: Unions vs Enums
Avoid
type IconSize = "sm" | "md" | "lg" | string;

function setSize(size: IconSize) { }

// TypeScript collapses the union to just 'string'
// No autocomplete for "sm", "md", "lg"
setSize("sm");   // No suggestions
setSize("xl");   // No error, no help
type IconSize = "sm" | "md" | "lg" | string;

function setSize(size: IconSize) { }

// TypeScript collapses the union to just 'string'
// No autocomplete for "sm", "md", "lg"
setSize("sm");   // No suggestions
setSize("xl");   // No error, no help

Prefer
type LooseAutocomplete<T extends string> =
  | T
  | (string & {});

type IconSize = LooseAutocomplete<"sm" | "md" | "lg">;

function setSize(size: IconSize) { }

setSize("sm");   // OK, autocomplete works
setSize("xl");   // OK, arbitrary strings allowed
type LooseAutocomplete<T extends string> =
  | T
  | (string & {});

type IconSize = LooseAutocomplete<"sm" | "md" | "lg">;

function setSize(size: IconSize) { }

setSize("sm");   // OK, autocomplete works
setSize("xl");   // OK, arbitrary strings allowed
Why avoid

Adding string to a union of string literals causes TypeScript to collapse everything into string. The literal values are technically still part of the union, but the IDE no longer suggests them because the broader string type subsumes them.

Why prefer

The string & {} trick preserves autocomplete for known values while still accepting any string. TypeScript sees string & {} as a separate branch from the literals, so it does not collapse the union. This is useful for icon sizes, color names, or any API where you want suggestions but not a closed set.

Total TypeScript: Loose Autocomplete