Engineering Notes
Home/Languages/TypeScript/Union Types
Languages · TypeScript

Union Types

A union type describes a value that can be one of several types, written with |.

💻 Languages● Beginner⏱ 5 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • What a Union Type Is
  • You Can Only Use What EVERY Member Has in Common
  • Unions of Literal Types
  • Discriminated Unions — the Most Important Union Pattern
  • Union vs Intersection (Quick Contrast)

1What a Union Type Is

A union type describes a value that can be one of several types, written with |.

ts
let id: number | string;
id = 42;      // ✅
id = "abc42"; // ✅
id = true;    // ❌ boolean is not part of the union

2You Can Only Use What EVERY Member Has in Common

ts
function printId(id: number | string) {
  console.log(id.toUpperCase()); // ❌ error — toUpperCase doesn't exist on `number`
}

Without knowing which member of the union a value actually is, TypeScript only allows operations valid for all possible members — accessing .toUpperCase() is unsafe because it would crash if id turned out to be a number at runtime. This is exactly the motivation for narrowing (its own topic): checking which specific member you have before using type-specific operations.

ts
function printId(id: number | string) {
  if (typeof id === "string") {
    console.log(id.toUpperCase()); // ✅ narrowed to `string` inside this block
  } else {
    console.log(id.toFixed(2));     // ✅ narrowed to `number` here
  }
}

3Unions of Literal Types

ts
type Direction = "up" | "down" | "left" | "right";

function move(dir: Direction) { /* ... */ }
move("up");       // ✅
move("diagonal"); // ❌ not one of the allowed literals

This pattern — a union of specific string/number literals — is the TypeScript equivalent of an enum in many codebases (compare with the Enums topic), and is extremely common for representing a fixed, closed set of allowed values.

4Discriminated Unions — the Most Important Union Pattern

A discriminated union is a union of object types that all share one common property (the "discriminant" or "tag") with a distinct literal value per variant — this lets TypeScript narrow the entire object shape based on checking just that one property.

ts
interface LoadingState { status: "loading"; }
interface SuccessState { status: "success"; data: string; }
interface ErrorState { status: "error"; message: string; }

type FetchState = LoadingState | SuccessState | ErrorState;

function render(state: FetchState) {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data;      // ✅ narrowed to SuccessState — .data is safely accessible
    case "error":
      return state.message;    // ✅ narrowed to ErrorState — .message is safely accessible
  }
}

Checking state.status narrows the checker's understanding of state's entire shape in each branch, not just the status property itself — this is the standard, idiomatic way to model state machines, API response shapes, and variant data in TypeScript.

Switching on state.status narrows FetchState down to just SuccessState or just ErrorState in each branch
Switching on state.status narrows FetchState down to just SuccessState or just ErrorState in each branch

5Union vs Intersection (Quick Contrast)

ts
type UnionExample = { a: number } | { b: string };        // EITHER shape — has a OR b (or check first)
type IntersectionExample = { a: number } & { b: string };  // BOTH shapes — has a AND b

Full depth on intersections is their own topic — the short version: | means "could be any of these," & means "must satisfy all of these simultaneously."

Under the HoodHow the Checker Represents and Checks Unions▾

A union type is stored as a set of constituent types

Internally, number | string isn't a new atomic type — it's a structure holding a list of its constituent types (number, string). Most operations on unions — assignability, narrowing, property access restrictions — are defined in terms of iterating over this constituent list.

Assignability rules are asymmetric depending on direction

ts
// assigning INTO a union: value must match AT LEAST ONE member
let id: number | string = 42; // ✅ number matches one member

// assigning a union OUT to a narrower target: EVERY member must match
function needsString(s: string) {}
declare const maybe: number | string;
// needsString(maybe); // ❌ number doesn't satisfy string — fails because NOT EVERY member matches

This is why section 2's restriction exists mechanically: calling a string-only method on a number | string value would only be safe if EVERY constituent type supported that method — the checker literally verifies the operation against each constituent in the list, and rejects it if even one fails.

Discriminated union narrowing is control-flow analysis over literal types

When you check state.status === "success", the checker doesn't special-case discriminated unions with unique logic — it applies its general control flow narrowing algorithm (full mechanics in the Type Guards and Narrowing topic): within the "success" branch, it eliminates every constituent of FetchState whose status property's literal type isn't compatible with "success", leaving only SuccessState as the remaining possible type for state in that branch. The "magic" of discriminated unions is really just literal-type narrowing applied to a property, generalized correctly across a whole union of object shapes.

Union simplification and deduplication

ts
type A = string | string;      // simplifies to just `string`
type B = string | any;          // simplifies to `any` — any absorbs everything
type C = "a" | "b" | string;     // simplifies to just `string` — the wider type absorbs the literals

The checker actively simplifies redundant or absorbed union members when constructing the internal representation — a union isn't just naively stored as written; overlapping or dominated members collapse automatically.

⚠️ Common Pitfalls

  1. Calling a member-specific method without narrowing first — always a compile error; you must narrow (via typeof, in, a discriminant check, etc.) before accessing anything not common to every union member.
  2. Forgetting to add a default/exhaustiveness check in a switch — an unhandled new variant added to the union later won't be caught unless you use the exhaustiveness-checking pattern (assigning the unreachable case to a never-typed variable — covered in the Any/Unknown/Never topic).
  3. Widening a literal union accidentally — assigning a Direction-typed value to a plain let string variable widens it, losing the restriction (see the Type Inference topic's widening rules).
  4. Designing a discriminated union without a shared, uniquely-literal discriminant property — narrowing only works this cleanly when one property has a distinct literal type per variant; a shared non-literal property (like a plain string "status") won't narrow anything.

📋 Quick Reference

text
A | B                     → value is A OR B — only shared members usable without narrowing
{ status: "x", ... } | ...  → discriminated union — switch/if on the shared literal tag to narrow
typeof / in / tag check      → narrows a union down to specific member(s)

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is a union type?core▾

A value that can be one of several types: string | number. You can only use members common to all types until you narrow with checks.

Follow-ups interviewers ask
  • Union of literals? type Status = 'idle' | 'loading' | 'error' acts like an enum.
Q2What is a discriminated union?core▾

A union of object types sharing a literal 'tag' property (kind, type, status), letting TS narrow by checking the tag in if/switch. Ideal for state machines, actions and API results.

Follow-ups interviewers ask
  • Example? { status: 'success'; data: T } | { status: 'error'; error: string }.
Q3How do you enforce exhaustive handling?core▾

In the default branch assign the value to never: const _exhaustive: never = action;. Adding a new union member makes compilation fail until handled.

Follow-ups interviewers ask
  • Alternative? satisfies never or a helper assertNever.
Q4Why are discriminated unions better than optional fields?senior▾

They make impossible states unrepresentable: { loading: boolean; data?: T; error?: string } allows data and error together; a union doesn't.

Follow-ups interviewers ask
  • Design principle? Make illegal states unrepresentable.
Q5How do unions interact with function parameters?core▾

Accepting a union means handling all cases; returning one forces callers to narrow. Overloads or generics can provide more precise relationships between input and output.

Follow-ups interviewers ask
  • Example of a generic replacing a union? function first<T>(arr: T[]): T | undefined.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.