Union Types
A union type describes a value that can be one of several types, written with |.
🎯 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 |.
let id: number | string;
id = 42; // ✅
id = "abc42"; // ✅
id = true; // ❌ boolean is not part of the union2You Can Only Use What EVERY Member Has in Common
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.
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
type Direction = "up" | "down" | "left" | "right";
function move(dir: Direction) { /* ... */ }
move("up"); // ✅
move("diagonal"); // ❌ not one of the allowed literalsThis 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.
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.
5Union vs Intersection (Quick Contrast)
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 bFull 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
// 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 matchesThis 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
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 literalsThe 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
- 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. - 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 anever-typed variable — covered in the Any/Unknown/Never topic). - Widening a literal union accidentally — assigning a
Direction-typed value to a plainlet stringvariable widens it, losing the restriction (see the Type Inference topic's widening rules). - 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
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
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.
- 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.
- 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.
- Alternative?
satisfies neveror a helperassertNever.
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.
- 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.
- 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.