Chapter 3

Union, Intersection & Literal Types: Discriminated Unions in Practice

Union Types: How A | B Actually Works

A union type says "this value is either A or B." The most direct use case is a function that accepts multiple input forms:

// Accept either a string slug or a numeric ID
function findUser(id: string | number) {
  if (typeof id === "string") {
    return db.findBySlug(id);   // id is string here
  }
  return db.findById(id);       // id is number here
}

Union members don't have to be primitives โ€” objects work too:

type StringOrArray = string | string[];

function normalize(input: StringOrArray): string[] {
  if (typeof input === "string") {
    return [input];
  }
  return input;  // TypeScript knows this branch is string[]
}

Literal Types: More Precise Than string

A literal type is the type of a specific value. "GET" is not just any string โ€” it is a type whose only inhabitant is the string "GET".

// HTTP methods are a closed set โ€” no reason to accept arbitrary strings
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

function request(url: string, method: HttpMethod) {
  return fetch(url, { method });
}

request("/api/users", "GET");     // OK
request("/api/users", "FETCH");   // Error: not a valid method

// Numeric literals work the same way
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;

function rollDice(): DiceValue {
  return (Math.floor(Math.random() * 6) + 1) as DiceValue;
}

Use as const to infer literal types from object values:

const CONFIG = {
  env: "production",
  port: 3000,
} as const;

// CONFIG.env is "production", not string
// CONFIG.port is 3000, not number
type Env = typeof CONFIG.env;  // "production"

Type Narrowing: Helping TypeScript Pick the Right Branch

When you have a union type, you need to narrow it before accessing members specific to one variant. TypeScript recognises four built-in narrowing forms.

1. typeof โ€” for primitives

function format(value: string | number | boolean): string {
  if (typeof value === "string") {
    return value.toUpperCase();   // string methods available
  }
  if (typeof value === "number") {
    return value.toFixed(2);      // number methods available
  }
  return value ? "YES" : "NO";   // boolean
}

2. instanceof โ€” for class instances

class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

function handleError(err: Error | ApiError) {
  if (err instanceof ApiError) {
    console.log(`HTTP ${err.statusCode}: ${err.message}`);
  } else {
    console.log(`Unknown error: ${err.message}`);
  }
}

3. in operator โ€” for property presence

interface Dog { bark(): void; breed: string; }
interface Cat { meow(): void; indoor: boolean; }

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

4. Custom type guards โ€” is keyword

interface AdminUser { role: "admin"; permissions: string[]; }
interface GuestUser { role: "guest"; }

type User = AdminUser | GuestUser;

// The return type `user is AdminUser` tells TypeScript:
// when this function returns true, treat the argument as AdminUser
function isAdmin(user: User): user is AdminUser {
  return user.role === "admin";
}

function getPermissions(user: User): string[] {
  if (isAdmin(user)) {
    return user.permissions;  // TypeScript confirms AdminUser here
  }
  return [];
}

Discriminated Unions: Modelling State Machines

A discriminated union is a union where every member carries a shared field with a distinct literal value โ€” called the discriminant. TypeScript uses this field to narrow the type exactly.

Real Example 1: API Response States

Without discriminated unions, state is represented with optional fields, and illegal states are representable:

// Anti-pattern: optional fields to represent state โ€” which fields are active?
interface ApiState {
  loading: boolean;
  data?: string[];
  error?: Error;
}

function render(state: ApiState) {
  // Can data and error both be set at once? TypeScript won't tell you.
  if (state.data && state.error) { /* ambiguous */ }
}

Rewrite with a discriminated union โ€” every state is exclusive and complete:

interface LoadingState {
  kind: "loading";
}

interface SuccessState {
  kind: "success";
  data: string[];
  timestamp: number;
}

interface ErrorState {
  kind: "error";
  error: Error;
  retryCount: number;
}

type ApiState = LoadingState | SuccessState | ErrorState;

function render(state: ApiState): string {
  switch (state.kind) {
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript confirms state.data is available
      return state.data.join(", ");
    case "error":
      // TypeScript confirms state.error and state.retryCount are available
      return `Error: ${state.error.message} (retry ${state.retryCount})`;
  }
}

const state: ApiState = { kind: "success", data: ["a", "b"], timestamp: Date.now() };
console.log(render(state));  // "a, b"

Real Example 2: Shape Types with Exhaustiveness Checking

interface Circle {
  kind: "circle";
  radius: number;
}

interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

type Shape = Circle | Rectangle | Triangle;

// Exhaustiveness helper: if a case is missed, TypeScript errors here
// because Shape is not assignable to never
function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
      // If you add a new Shape variant but forget a case here,
      // TypeScript will error: "Triangle is not assignable to never"
  }
}

Modelling a State Machine

type TrafficLight =
  | { state: "red";    nextState: "green" }
  | { state: "green";  nextState: "yellow" }
  | { state: "yellow"; nextState: "red" };

function next(light: TrafficLight): TrafficLight {
  switch (light.state) {
    case "red":    return { state: "green",  nextState: "yellow" };
    case "green":  return { state: "yellow", nextState: "red"    };
    case "yellow": return { state: "red",    nextState: "green"  };
  }
}

Intersection Types: A & B

An intersection type means "satisfies both A and B simultaneously." The result has all properties of both.

Use Case 1: Extending Third-Party Types

// Imagine this comes from a library you cannot modify
interface ThirdPartyUser {
  id: number;
  name: string;
}

// Extend with intersection instead of touching the original
type AppUser = ThirdPartyUser & {
  role: "admin" | "user";
  createdAt: Date;
};

const user: AppUser = {
  id: 1,
  name: "Alice",
  role: "admin",
  createdAt: new Date(),
};

Use Case 2: Mixin Pattern

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

interface SoftDeletable {
  deletedAt: Date | null;
  isDeleted: boolean;
}

type AuditableEntity = Timestamped & SoftDeletable & {
  createdBy: string;
};

type Post = AuditableEntity & {
  title: string;
  content: string;
};

const post: Post = {
  title: "Hello",
  content: "World",
  createdAt: new Date(),
  updatedAt: new Date(),
  deletedAt: null,
  isDeleted: false,
  createdBy: "alice",
};

Intersection Pitfalls

// Primitive intersections collapse to never
type Impossible = string & number;  // never

// Distribution over unions
type A = (string | number) & string;  // string โ€” only string satisfies both

Anti-Pattern: Optional Fields Instead of Discriminated Unions

// Anti-pattern: optional fields allow illegal states
interface PaymentState {
  isPending?: boolean;
  successAmount?: number;
  failureReason?: string;
}

// Nothing prevents isPending=true AND successAmount=100 simultaneously.
// TypeScript can't help you catch this contradiction.

// Correct: discriminated union makes illegal states unrepresentable
type Payment =
  | { status: "pending" }
  | { status: "success"; amount: number; transactionId: string }
  | { status: "failed";  reason: string; retryable: boolean };

// In "pending" state, `amount` simply does not exist โ€” enforced by the type system.

Key Takeaways

Feature Syntax Best Used For
Union type A | B Value can be one of several types
Literal type "get" | "post" Restrict to a known set of values
typeof narrowing typeof x === "string" Distinguishing primitives
instanceof narrowing x instanceof MyClass Distinguishing class instances
in narrowing "field" in x Distinguishing interface/object shapes
Custom type guard (x): x is T Complex narrowing logic
Discriminated union Shared kind field Mutually exclusive states, exhaustiveness
Intersection type A & B Combining types, Mixin pattern

Next chapter: the key differences between interface and type alias, and a decision tree to help you choose between them.

Rate this chapter
4.8  / 5  (83 ratings)

๐Ÿ’ฌ Comments