On this page

Union and intersection types

12 min read TextCh. 2 — Compound Types

Union types

A union type describes a value that can be one of several types. You create a union with the | operator:

type StringOrNumber = string | number;

function formatValue(value: StringOrNumber): string {
  if (typeof value === "string") {
    return value.toUpperCase();
  }
  return value.toFixed(2);
}

formatValue("hello"); // "HELLO"
formatValue(9.99);    // "9.99"
formatValue(true);    // Error: Argument of type 'boolean' is not assignable to parameter of type 'StringOrNumber'

Union types are ubiquitous in TypeScript. Every optional property (name?: string) is implicitly a union with undefined (name: string | undefined). null-able values are unions too (string | null).

Literal types

A literal type is a type whose only inhabitant is one specific value. Strings, numbers, and booleans all have literal forms:

type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type StatusCode = 200 | 201 | 400 | 401 | 404 | 500;
type Enabled = true;   // a type that can only be true

function request(method: HttpMethod, url: string): void {
  // method can only be one of the five HTTP verbs
  fetch(url, { method });
}

Literal types are most powerful when combined into unions. A union of string literals is equivalent to an enum but simpler, produces no JavaScript output, and works naturally with JSON serialization.

Template literal types

TypeScript 4.1 introduced template literal types — literal types constructed from string interpolation patterns:

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// Equivalent to: "onClick" | "onFocus" | "onBlur"

type CSSProperty = "padding" | "margin";
type CSSDirection = "Top" | "Right" | "Bottom" | "Left";
type CSSPropertyWithDirection = `${CSSProperty}${CSSDirection}`;
// "paddingTop" | "paddingRight" | ... | "marginBottom" | "marginLeft"

These are computed at the type level — zero runtime cost.

Intersection types

An intersection type requires a value to satisfy all intersected types simultaneously. You create intersections with the & operator:

type Named = { name: string };
type Aged = { age: number };

type Person = Named & Aged;

const alice: Person = {
  name: "Alice",
  age: 30,
}; // must have both name and age

Intersections are additive: the resulting type has all properties from all members. They are the type alias equivalent of interface extends.

A common pattern is using intersections to add cross-cutting concerns like timestamps, soft-delete flags, or audit information:

type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type WithSoftDelete = { deletedAt: Date | null };

type BaseEntity = WithId & WithTimestamps & WithSoftDelete;

type User = BaseEntity & {
  email: string;
  name: string;
};

type Order = BaseEntity & {
  userId: string;
  total: number;
  items: string[];
};

Discriminated unions

A discriminated union (also called a tagged union) is a union of object types where each member has a unique literal property — the discriminant. TypeScript uses this discriminant to narrow the union to a specific member inside conditional branches.

type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle";  base: number;  height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // TypeScript knows shape is { kind: "circle"; radius: number }
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

Without the discriminant, TypeScript cannot determine which union member is active in each case, and you would need explicit type guards or assertions everywhere.

Real-world discriminated union: API responses

Discriminated unions model asynchronous state beautifully:

type ApiResponse<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string; retryable: boolean };

function handleUserResponse(response: ApiResponse<User[]>): string {
  switch (response.status) {
    case "idle":
      return "Not started";
    case "loading":
      return "Fetching users…";
    case "success":
      return `Loaded ${response.data.length} users`;
    case "error":
      return `Failed: ${response.error}`;
  }
}

This pattern eliminates impossible state combinations. You can never accidentally access response.data when response.status is "error" — TypeScript prevents it.

Exhaustive checking with never

When using a discriminated union in a switch statement, you can add a default branch that assigns the remaining value to never. If every case is handled, the default branch is unreachable. If you add a new union member and forget to handle it, TypeScript reports an error:

type Color = "red" | "green" | "blue";

function describeColor(color: Color): string {
  switch (color) {
    case "red":   return "warm";
    case "green": return "cool";
    case "blue":  return "cool";
    default: {
      const exhaustive: never = color; // ERROR if Color has unhandled members
      throw new Error(`Unhandled color: ${exhaustive}`);
    }
  }
}

Now add "yellow" to Color:

type Color = "red" | "green" | "blue" | "yellow";
// TypeScript immediately reports:
// Type '"yellow"' is not assignable to type 'never'

This technique turns missed cases into compile errors rather than silent runtime bugs.

Narrowing union types

TypeScript narrows union types automatically through several mechanisms:

type StringOrNumber = string | number;

function process(value: StringOrNumber): string {
  // typeof narrowing
  if (typeof value === "string") {
    return value.trim(); // value: string here
  }
  return String(value); // value: number here
}

// instanceof narrowing
function handleError(err: Error | string): void {
  if (err instanceof Error) {
    console.error(err.message); // err: Error here
  } else {
    console.error(err);         // err: string here
  }
}

// in operator narrowing
type Cat = { meow(): void };
type Dog = { bark(): void };

function makeNoise(animal: Cat | Dog): void {
  if ("meow" in animal) {
    animal.meow(); // animal: Cat here
  } else {
    animal.bark(); // animal: Dog here
  }
}

TypeScript performs control flow analysis — it tracks which narrowing conditions are active at every point in the code and adjusts the inferred type accordingly.

Practice

  1. Model a payment system with a discriminated union: CreditCardPayment, BankTransferPayment, and CryptocurrencyPayment. Each should have a method discriminant and its own unique fields. Write a function that processes any payment and returns a formatted receipt string.
  2. Create a template literal type that generates all valid CSS border-{side} property names (border-top, border-right, border-bottom, border-left). Verify that an invalid property name is a compile error.
  3. Implement an exhaustive switch with a never check. Add a new union member and confirm TypeScript reports the error immediately.

Always include a discriminant field in union members
A discriminated union is only as useful as its discriminant. Always use a `kind`, `type`, or `tag` field with a unique string literal value in each union member. This gives TypeScript enough information to narrow the type completely inside each case and enables exhaustive checking.
Intersection of incompatible types produces never
If you intersect two types that have the same property with incompatible types, that property becomes `never`. For example: `type A = { x: string } & { x: number }` results in a type where `x: never` — meaning no value can ever satisfy both constraints simultaneously. TypeScript will not warn you at the intersection definition, only when you try to use it.
Union narrowing happens automatically
TypeScript narrows union types automatically based on control flow: `typeof` checks, `instanceof` checks, equality comparisons, truthiness checks, and property existence checks (`in` operator) all narrow the type within that branch. You rarely need explicit type assertions if you structure your code with clear narrowing conditions.
// Each variant has a unique literal "kind" field — the discriminant
type LoadingState = {
  kind: "loading";
};

type SuccessState = {
  kind: "success";
  data: string[];
};

type ErrorState = {
  kind: "error";
  message: string;
  code: number;
};

// The union of all three variants
type FetchState = LoadingState | SuccessState | ErrorState;

function renderState(state: FetchState): string {
  switch (state.kind) {
    case "loading":
      return "Loading…";
    case "success":
      // TypeScript knows state is SuccessState here
      return state.data.join(", ");
    case "error":
      // TypeScript knows state is ErrorState here
      return `Error ${state.code}: ${state.message}`;
    default: {
      // Exhaustive check — compile error if a new variant is missed
      const exhaustive: never = state;
      return exhaustive;
    }
  }
}