Union Types
A union type allows a value to be one of several types. You write it by joining types with a pipe character (|). Unions are one of TypeScript's most expressive features — they let you model real-world values that can legitimately be more than one shape.
Basic Union Syntax
// A value that is either a number or a string
let id: number | string;
id = 101; // OK — number
id = "abc-1"; // OK — string
// id = true; // Error — boolean is not in the union
// Union in a function parameter
function formatId(id: number | string): string {
return id.toString();
}
// Union in a return type
function parse(input: string): number | null {
const n = Number(input);
return isNaN(n) ? null : n;
}Narrowing Unions
When you hold a union type, TypeScript only lets you use properties and methods that exist on every member. To access member-specific APIs, you must narrow — prove to TypeScript which member you are working with.
function printId(id: number | string) {
// id.toUpperCase(); // Error — number doesn't have toUpperCase
// Narrow with typeof
if (typeof id === "string") {
console.log(id.toUpperCase()); // id: string here
} else {
console.log(id.toFixed(0)); // id: number here
}
}
// Narrowing with instanceof
function formatDate(value: Date | string): string {
if (value instanceof Date) {
return value.toISOString(); // value: Date
}
return value; // value: string
}
// Narrowing with in (for object unions)
type Cat = { meow(): void };
type Dog = { bark(): void };
function makeSound(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow(); // animal: Cat
} else {
animal.bark(); // animal: Dog
}
}Discriminated Unions
A discriminated union (also called a tagged union) is a union where every member has a common literal property — the discriminant — that uniquely identifies the type. TypeScript narrows to the correct member when you check the discriminant.
This pattern is the foundation of safe, extensible state machines.
// Each member has a unique 'type' literal
type LoadingState = { type: "loading" };
type SuccessState = { type: "success"; data: string[] };
type ErrorState = { type: "error"; message: string };
type State = LoadingState | SuccessState | ErrorState;
function render(state: State): string {
switch (state.type) {
case "loading": return "Loading...";
case "success": return state.data.join(", "); // state: SuccessState
case "error": return `Error: ${state.message}`; // state: ErrorState
}
}
// Real-world: Redux-style actions
type Action =
| { type: "INCREMENT"; amount: number }
| { type: "DECREMENT"; amount: number }
| { type: "RESET" };
function reducer(count: number, action: Action): number {
switch (action.type) {
case "INCREMENT": return count + action.amount;
case "DECREMENT": return count - action.amount;
case "RESET": return 0;
}
}type — kind, tag, and status are equally common. The key is that each member has a unique literal value for that property.Literal Unions
Unions of string, number, or boolean literals constrain a value to a fixed set of options — a compile-time enum alternative.
type Direction = "north" | "south" | "east" | "west";
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;
type Bit = 0 | 1;
function move(direction: Direction) {
console.log(`Moving ${direction}`);
}
move("north"); // OK
// move("up"); // Error: Argument of type '"up"' is not assignable
// Literal union as a configuration option
function setAlignment(align: "left" | "center" | "right") {
// ...
}
// Combining literals with other types
type Result = "ok" | "error" | number; // sometimes a numeric codeUnion Reduction and Distribution
TypeScript automatically simplifies redundant unions. A union that contains a supertype absorbs its subtypes. Understanding this helps you predict inferred types.
// string absorbs string literals when combined with string
type T1 = string | "hello"; // simplifies to: string
// unknown absorbs everything
type T2 = unknown | string; // unknown
// never is the identity element — it disappears
type T3 = string | never; // string
// Duplicate members collapse
type T4 = string | string | number; // string | number
// Example: why this matters in practice
type Config = {
theme: "light" | "dark" | string; // effectively just: string
// ^^^^^^^^^^^^^^^^^^
// 'string' swallows the literals — use a type alias instead
};
// Better:
type Theme = "light" | "dark";
type Config2 = { theme: Theme };"light" | "dark" | string) loses the literal precision. TypeScript widens the whole union to string. Keep literals separate from their base type.Optional vs. Union with undefined
An optional property (prop?) is shorthand for prop: T | undefined. With strictNullChecks, these are equivalent but have subtle behavioral differences.
// These two are equivalent with strictNullChecks
type A = { name?: string };
type B = { name: string | undefined };
// Difference: optional allows the key to be absent entirely
const a: A = {}; // OK — key not present
const b: B = { name: undefined }; // OK — key present but undefined
// const b2: B = {}; // Error — key must be present
// Function parameters: optional vs. union
function greet(name?: string) {
// name: string | undefined
console.log(`Hello, ${name ?? "stranger"}!`);
}
greet(); // OK
greet("Alice"); // OK
greet(undefined); // OK (explicitly passing undefined)Narrowing with Type Predicates
When built-in narrowing is not enough, write a type predicate — a function whose return type asserts what the argument is.
type Fish = { swim(): void };
type Bird = { fly(): void };
// Type predicate: 'pet is Fish'
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim(); // pet: Fish
} else {
pet.fly(); // pet: Bird
}
}
// Practical example: filter with type predicate
const values: (string | null | undefined)[] = ["a", null, "b", undefined, "c"];
function isString(v: string | null | undefined): v is string {
return typeof v === "string";
}
const strings = values.filter(isString);
// strings: string[] (nulls and undefineds removed from the type)Array.filter + type predicate pattern is one of the most common real-world uses of union narrowing — it filters both the runtime values and the type in one step.Union Types in Practice
// API response modeling
type ApiResponse<T> =
| { status: "ok"; data: T }
| { status: "error"; message: string; code: number };
async function fetchUser(id: number): Promise<ApiResponse<{ name: string }>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return { status: "error", message: res.statusText, code: res.status };
}
const data = await res.json();
return { status: "ok", data };
} catch (e) {
return { status: "error", message: String(e), code: 0 };
}
}
// Caller always handles both cases
const result = await fetchUser(1);
if (result.status === "ok") {
console.log(result.data.name);
} else {
console.error(`${result.code}: ${result.message}`);
}Common Patterns Summary
Pattern | Example | Use case |
|---|---|---|
Primitive union | string | number | IDs, mixed inputs |
Literal union | "asc" | "desc" | Constrained string options |
Nullable union | string | null | Values that may be absent |
Discriminated union | { type: "a" } | { type: "b" } | State machines, actions |
Optional param | name?: string | Function params with defaults |
Filter with predicate | arr.filter(isString) | Remove nulls from typed arrays |
Union Exhaustiveness
When you switch on a discriminated union, TypeScript can verify that every variant is handled. Adding a never check in the default branch turns a missing case into a compile error.
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
type Notification =
| { kind: "email"; to: string }
| { kind: "sms"; phone: string }
| { kind: "push"; deviceId: string };
function send(n: Notification): void {
switch (n.kind) {
case "email": console.log(`Emailing ${n.to}`); break;
case "sms": console.log(`Texting ${n.phone}`); break;
case "push": console.log(`Pushing to ${n.deviceId}`); break;
default: assertNever(n); // compile error if a case is missing
}
}Mapped Types Over Unions
You can use a union as the key set of a mapped type to enforce that every variant is handled in an object map — an alternative to switch statements.
type Status = "active" | "pending" | "suspended" | "closed";
// Record requires every key in the union to be present
const STATUS_LABELS: Record<Status, string> = {
active: "Active",
pending: "Pending review",
suspended: "Suspended",
closed: "Closed",
// Omitting any key is a compile error
};
// Dynamic lookup — always type-safe
function getLabel(status: Status): string {
return STATUS_LABELS[status];
}Record<Union, T> pattern is often cleaner than a switch statement when the mapping is pure data with no branching logic.Union Types in Generic Constraints
// Accept only string or number (a common constraint)
function formatValue<T extends string | number>(value: T): string {
return String(value);
}
formatValue(42); // OK
formatValue("hello"); // OK
// formatValue(true); // Error
// keyof produces a union of string keys
interface User { id: number; name: string; email: string }
type UserKey = keyof User; // "id" | "name" | "email"
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
const name = pluck(user, "name"); // string
const id = pluck(user, "id"); // numberNarrowing with switch (true)
A lesser-known pattern uses switch (true) to combine multiple narrowing conditions in a readable switch block — useful when the discriminant is a complex expression rather than a single property.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (true) {
case shape.kind === "circle":
return Math.PI * shape.radius ** 2;
case shape.kind === "square":
return shape.side ** 2;
case shape.kind === "rectangle":
return shape.width * shape.height;
default:
// TypeScript still narrows correctly here
const _: never = shape;
return _;
}
}
// Also useful for non-discriminant conditions
function classify(value: string | number | boolean): string {
switch (true) {
case typeof value === "string" && value.length > 10: return "long string";
case typeof value === "string": return "short string";
case typeof value === "number" && value < 0: return "negative";
case typeof value === "number": return "positive number";
default: return "boolean";
}
}switch (true) pattern is particularly readable when each case requires a compound condition. It keeps the type narrowing benefits of a regular switch while allowing arbitrary boolean expressions.Summary
Union types are written with | between each member type
You can only use members common to all union variants without narrowing
Narrow with typeof, instanceof, in, and custom type predicates
Discriminated unions use a shared literal property for exhaustive switching
Literal unions constrain values to a fixed set — a lightweight enum
Mixing literals with their base type loses precision — keep them separate
type predicates power safe filter and guard helpers
Record<Union, T> enforces exhaustive object maps at compile time
keyof produces a union of all property names — the basis of safe generic accessors
switch (true) allows compound boolean conditions while retaining TypeScript narrowing