TypeScript Union Types

A union type lets a value be one of several types at once, written with the pipe (|) operator. Instead of forcing a variable into a single rigid type, a union says “this can be a string, or a number, or something else” — and TypeScript will make sure you handle every possibility safely before you use the value. Unions are one of the most-used features in TypeScript because real-world data is rarely one shape: function parameters accept multiple input kinds, API responses vary by status, and state machines have distinct modes. Understanding unions well is essential to writing idiomatic TypeScript.

Overview / How it works

A union type A | B describes a value that could be an A or a B. Structurally, TypeScript treats this as “any value whose type is assignable to at least one of the members.” When you have a value of a union type, the compiler only lets you access members (properties, methods) that exist on every member of the union — because it can’t know at compile time which branch you actually have. This is the key mental model: a union type is permissive on input (many types can flow in) but restrictive on use (you can only safely do what all members support) until you narrow it.

Narrowing is the process of using runtime checks (typeof, instanceof, in, equality checks, custom type guards) so the compiler can figure out, within a branch of your code, exactly which member of the union you’re dealing with. Once narrowed, you get full access to that specific type’s members. This is TypeScript’s “control flow analysis” at work — it tracks how conditionals shrink a union type as your code executes.

It’s also important to remember that types are erased at runtime. A union type like string | number exists purely for the compiler; the compiled JavaScript has no notion of it at all. All of the safety a union gives you happens during compilation — at runtime, you’re just working with plain JS values, and your narrowing checks (like typeof value === "string") are ordinary JavaScript operators that must exist independently of the type system.

Syntax

The general form of a union type:

type UnionName = TypeA | TypeB | TypeC;

function fn(param: TypeA | TypeB): ReturnType {
  // ...
}
  • type UnionName = ... — an optional type alias that gives the union a readable name so you don’t repeat it everywhere.
  • TypeA | TypeB | TypeC — any number of types separated by |. Members can be primitives (string, number, boolean), literal types ("success" | "error"), object types, arrays, or other unions (unions flatten automatically).
  • A union can be used anywhere a type is expected: variable annotations, function parameters, return types, generic constraints, and interface/object properties.
Narrowing technique Use case Example check
typeof Primitives: string, number, boolean, symbol, bigint, undefined, function typeof x === "string"
instanceof Class instances x instanceof Date
in Checking for a property that only some union members have "radius" in shape
Discriminated union Object unions sharing a common literal “tag” property switch (shape.kind)
Equality narrowing Literal type unions status === "loading"
Custom type guard Complex or reusable checks function isFish(a: Animal): a is Fish

Examples

Example 1: A basic union parameter

type ID = string | number;

function printId(id: ID): void {
  console.log(`Your ID is: ${id}`);
}

printId(101);
printId("202");
Output:
Your ID is: 101
Your ID is: 202

The ID type alias documents intent — an identifier may come from a database (number) or a URL parameter (string). Both calls are valid because both 101 and "202" are assignable to string | number. Inside printId, template-literal interpolation works on either type without narrowing, since string interpolation accepts anything.

Example 2: Narrowing with typeof

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

console.log(formatValue("hello"));
console.log(formatValue(3.14159));
console.log(formatValue(true));
Output:
HELLO
3.14
YES

Each branch narrows the three-member union down to exactly one type. Inside the first if, TypeScript knows value is string, so .toUpperCase() is available; inside the else if, it’s narrowed to number, allowing .toFixed(2); by the final else, TypeScript has eliminated string and number by process of elimination, leaving only boolean.

Example 3: Discriminated unions of object types

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

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

type Shape = Circle | Rectangle;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
  }
}

const shapes: Shape[] = [
  { kind: "circle", radius: 2 },
  { kind: "rectangle", width: 3, height: 4 },
];

for (const shape of shapes) {
  console.log(`${shape.kind} area: ${area(shape).toFixed(2)}`);
}
Output:
circle area: 12.57
rectangle area: 12.00

This is the most common and powerful union pattern: a shared literal kind property (the “discriminant” or “tag”) lets the compiler pick which interface applies inside each switch case. Inside case "circle", shape is narrowed to Circle, so shape.radius is valid; inside case "rectangle", it’s narrowed to Rectangle, exposing width and height. Without the kind tag, TypeScript would have no reliable way to distinguish the two object shapes.

How it works step by step / Under the hood

  • 1. Declaration: When you write string | number, the compiler records a union type internally as a set of constituent types.
  • 2. Assignability check: Any value assigned to a union-typed location must be assignable to at least one member — TypeScript checks each member in turn.
  • 3. Member access restriction: When you access a property or call a method on a union-typed value, TypeScript only allows it if that member exists (with compatible signatures) on every constituent — otherwise it reports an error.
  • 4. Control flow narrowing: As execution passes through if/switch/&& conditions using a supported narrowing technique, the compiler updates its internal understanding of the variable’s type for that specific code path.
  • 5. Erasure: After compilation, every trace of the union type disappears from the emitted JavaScript. The typeof/in/tag checks you wrote remain, because they’re real JS operators — but there’s no runtime union type to inspect.

Common Mistakes

Mistake 1: Using a member before narrowing

function printLength(value: string | number) {
  console.log(value.length); // Error: Property 'length' does not exist on type 'number'.
}

TypeScript rejects this because length exists on string but not on number — and a union only exposes members shared by all constituents. Narrow first:

function printLength(value: string | number): void {
  if (typeof value === "string") {
    console.log(value.length);
  } else {
    console.log(String(value).length);
  }
}

printLength("hello");
printLength(12345);
Output:
5
5

Mistake 2: Confusing a union of arrays with an array of a union

let mixedList: string[] | number[] = [];
mixedList.push("a"); // Error: Argument of type 'string' is not assignable to parameter of type 'never'.

string[] | number[] means “either an all-string array or an all-number array,” not “an array that can freely mix strings and numbers.” Because push‘s parameter type differs between the two array types, TypeScript can only allow arguments assignable to both — which for incompatible primitives collapses to never. If you actually want a single array holding mixed values, wrap the union in parentheses so it applies to the element type instead:

let list: (string | number)[] = [];
list.push("a");
list.push(1);
console.log(list);
Output:
[ 'a', 1 ]

Best Practices

  • Name non-trivial unions with a type alias (e.g. type Status = "idle" | "loading" | "error") instead of repeating the union inline everywhere.
  • Prefer discriminated unions (a shared literal “tag” property) over loosely related object shapes — they make narrowing exhaustive and mistake-proof.
  • Keep unions as narrow as possible; a union of specific literal types ("GET" | "POST") catches far more bugs than a broad string.
  • Always narrow before accessing type-specific members — never reach for as type assertions to bypass a union just to silence an error.
  • Watch for the array-union gotcha: write (A | B)[] for a mixed-content array, and A[] | B[] only when you truly mean “one homogeneous array or the other.”
  • Enable strict mode so the compiler actively catches unnarrowed union member access.

Practice Exercises

  • Exercise 1: Write a function describe(value: string | number | null) that returns "empty" for null, the uppercase string for a string, and "number: X" for a number. Use typeof and an equality check to narrow.
  • Exercise 2: Define a discriminated union type Result = { status: "success"; data: string } | { status: "failure"; error: string } and write a function that logs data on success or error on failure using a switch on status.
  • Exercise 3: Given type Weekday = "Mon" | "Tue" | "Wed" | "Thu" | "Fri", write a function isWeekday(value: string): value is Weekday (a custom type guard) that checks membership, then use it to narrow a plain string before passing it to a function that only accepts Weekday.

Summary

  • A union type (A | B) means a value can be any one of the listed types.
  • You can only access members shared by every constituent of a union until you narrow it.
  • Narrowing techniques include typeof, instanceof, in, equality checks, discriminated unions, and custom type guards.
  • Discriminated unions (object types sharing a common literal tag property) are the most robust pattern for modeling variants.
  • A[] | B[] and (A | B)[] mean different things — parentheses matter.
  • Union types exist only at compile time; they are fully erased from the emitted JavaScript.