TypeScriptUnion Types

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

TS
// 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;
}
Note
The pipe character at the start of multi-line unions is a stylistic convention for readability — TypeScript accepts both forms.
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.

TS
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.

TS
// 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;
  }
}
Tip
The discriminant property name does not have to be 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.

TS
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 code
Union Reduction and Distribution

TypeScript automatically simplifies redundant unions. A union that contains a supertype absorbs its subtypes. Understanding this helps you predict inferred types.

TS
// 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 };
Warning
Mixing literal types with their base type (e.g., "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.

TS
// 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.

TS
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)
Success
The 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

TS
// 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.

TS
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.

TS
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];
}
Tip
The 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

TS
// 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");    // number
Narrowing 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.

TS
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";
  }
}
Tip
The 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