Syntax Cache
BlogMethodFeaturesHow It WorksBuild a Game
  1. Home
  2. Cheat Sheets
  3. TypeScript
  4. TypeScript Union & Intersection Types Cheat Sheet
TypeScriptCheat Sheet

TypeScript Union & Intersection Types Cheat Sheet

Unions, intersections, discriminated unions, literal types, and exhaustive checking patterns -- with copy-ready examples for every scenario.

On this page
  1. 1Basic Union (A | B)
  2. 2Literal Unions
  3. 3Discriminated Unions
  4. 4Intersection Types (A & B)
  5. 5Union with null/undefined (Nullable)
  6. 6Narrowing Unions
  7. 7Exhaustive Switch Pattern
  8. 8Union vs Enum
  9. 9Common Union Patterns
Basic Union (A | B)Literal UnionsDiscriminated UnionsIntersection Types (A & B)Union with null/undefined (Nullable)Narrowing UnionsExhaustive Switch PatternUnion vs EnumCommon Union Patterns

Basic Union (A | B)

A union type accepts any one of the listed types. The pipe `|` means "or".

Accepting multiple types
function formatId(id: string | number): string {
  return `ID-${id}`;
}

formatId(123);     // => 'ID-123'
formatId('abc');   // => 'ID-abc'
// formatId(true); // => Error: boolean not assignable to string | number
Union in variable declarations
let result: string | null = null;

result = 'success';  // OK
result = null;        // OK
// result = 42;       // Error: number not assignable to string | null
Union narrows shared members
function getLength(value: string | number[]): number {
  // .length exists on both string and number[]
  return value.length;
}

getLength('hello');    // => 5
getLength([1, 2, 3]); // => 3

You can only access properties that exist on every member of the union without narrowing first.

Literal Unions

String and numeric literal types restrict a value to exact matches. Much stricter than plain `string` or `number`.

String literal union
type Direction = 'north' | 'south' | 'east' | 'west';

function move(dir: Direction): void {
  console.log(`Moving ${dir}`);
}

move('north');   // OK
// move('up');   // Error: 'up' not assignable to Direction
Numeric literal union
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

function roll(): DiceRoll {
  return (Math.floor(Math.random() * 6) + 1) as DiceRoll;
}
Boolean as a literal union
// boolean is actually: true | false
type Bool = true | false;

// This means you can narrow booleans the same way:
type IsAdmin = true;
type IsGuest = false;

TypeScript treats `boolean` as an alias for `true | false` internally. Knowing this helps explain why boolean works with discriminated unions.

Discriminated Unions

A shared literal property (the discriminant) lets TypeScript narrow the union to one specific member in each branch.

Shape example with kind discriminant
type Circle = { kind: 'circle'; radius: number };
type 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;
  }
}
API response pattern
type ApiResult<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function handleResult(result: ApiResult<string[]>) {
  if (result.status === 'success') {
    console.log(result.data);    // TS knows data exists here
  } else if (result.status === 'error') {
    console.log(result.message); // TS knows message exists here
  }
}
Redux-style action types
type Action =
  | { type: 'INCREMENT'; amount: number }
  | { type: 'DECREMENT'; amount: number }
  | { type: 'RESET' };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT': return state + action.amount;
    case 'DECREMENT': return state - action.amount;
    case 'RESET': return 0;
  }
}

The `type` property is the discriminant. Each branch narrows `action` to the matching member.

Intersection Types (A & B)

An intersection combines multiple types into one. The result must satisfy all constituent types simultaneously.

Combining object types
type HasId = { id: string };
type HasTimestamps = { createdAt: Date; updatedAt: Date };

type Entity = HasId & HasTimestamps;

// Must have all properties from both types
const post: Entity = {
  id: 'abc',
  createdAt: new Date(),
  updatedAt: new Date(),
};
Adding properties to a function parameter
type Loggable = { log(): void };
type Serializable = { toJSON(): string };

function process(item: Loggable & Serializable) {
  item.log();
  const json = item.toJSON();
}
Intersecting primitives produces never
type Impossible = string & number;   // => never

// This makes sense: no value is both a string and a number
// let x: Impossible = ???  // nothing works

Intersecting incompatible types produces `never`. This trips people up when intersecting object types with conflicting properties -- the conflicting property becomes `never`, not the whole type.

Union with null/undefined (Nullable)

With `strictNullChecks` on, you must explicitly include `null` or `undefined` in the type to allow those values.

Optional vs nullable
// Optional property -- can be missing entirely
interface UserOptional {
  name: string;
  nickname?: string;  // string | undefined
}

// Nullable property -- must be present, but can be null
interface UserNullable {
  name: string;
  nickname: string | null;
}

const a: UserOptional = { name: 'Alice' };                 // OK: nickname missing
const b: UserNullable = { name: 'Alice', nickname: null };  // OK: nickname is null
// const c: UserNullable = { name: 'Alice' };               // Error: nickname missing
Narrowing null away
function greet(name: string | null): string {
  if (name === null) {
    return 'Hello, stranger';
  }
  // TypeScript knows name is string here
  return `Hello, ${name.toUpperCase()}`;
}
Non-null assertion operator (!)
function getElement(id: string): HTMLElement {
  const el = document.getElementById(id);
  // el is HTMLElement | null

  return el!;  // Assert non-null (throws at runtime if null)
}

The `!` operator silences the null check. Use it sparingly -- it defeats the purpose of strictNullChecks. Prefer narrowing with an if-check or throwing an explicit error.

Narrowing Unions

TypeScript tracks control flow and narrows the union to a specific member after type checks.

typeof narrowing
function double(value: string | number): string | number {
  if (typeof value === 'string') {
    return value.repeat(2);   // string branch
  }
  return value * 2;           // number branch
}
in narrowing
type Fish = { swim(): void };
type Bird = { fly(): void };

function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    animal.swim();   // Fish
  } else {
    animal.fly();    // Bird
  }
}
instanceof narrowing
function formatError(err: Error | string): string {
  if (err instanceof Error) {
    return `${err.name}: ${err.message}`;
  }
  return err;  // string
}
Equality narrowing
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

function isReadOnly(method: HTTPMethod): boolean {
  if (method === 'GET') {
    return true;   // method is narrowed to 'GET'
  }
  return false;    // method is 'POST' | 'PUT' | 'DELETE'
}

See the Type Narrowing cheat sheet for the full set of narrowing techniques including type guards and assertion functions.

Exhaustive Switch Pattern

Assigning the unmatched value to `never` in a default case forces a compile error if you forget a union member.

Exhaustive check with never
type Status = 'pending' | 'active' | 'archived';

function statusLabel(status: Status): string {
  switch (status) {
    case 'pending':  return 'Pending';
    case 'active':   return 'Active';
    case 'archived': return 'Archived';
    default: {
      const _exhaustive: never = status;
      return _exhaustive;  // never reached if all cases handled
    }
  }
}

// If you add 'deleted' to Status and forget to handle it:
// => Error: Type 'deleted' not assignable to type 'never'
Helper function for exhaustive checks
function assertNever(value: never, msg?: string): never {
  throw new Error(msg ?? `Unexpected value: ${value}`);
}

type Shape = { kind: 'circle' } | { kind: 'square' };

function draw(shape: Shape) {
  switch (shape.kind) {
    case 'circle': /* ... */ break;
    case 'square': /* ... */ break;
    default: assertNever(shape);
  }
}

The assertNever helper doubles as a runtime guard -- if a value somehow reaches it (e.g., from unvalidated JSON), the throw provides a clear error instead of silent failure.

Union vs Enum

String literal unions and enums solve the same problem. Here is when to pick each.

String literal union (preferred in most cases)
type Color = 'red' | 'green' | 'blue';

// No runtime cost -- erased during compilation
// Autocomplete works in editors
// Easy to extend: type ExtColor = Color | 'yellow';

function paint(color: Color) { /* ... */ }
paint('red');
Enum (when you need runtime values)
enum Color {
  Red = 'RED',
  Green = 'GREEN',
  Blue = 'BLUE',
}

// Exists at runtime -- you can iterate over it
Object.values(Color); // => ['RED', 'GREEN', 'BLUE']

function paint(color: Color) { /* ... */ }
paint(Color.Red);
const enum (compromise)
const enum Direction {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT',
}

// Inlined at compile time -- no runtime object
const dir = Direction.Up;  // compiles to: const dir = 'UP'

const enums are inlined during compilation so they have zero runtime cost. But they break with `--isolatedModules` (used by Babel, esbuild, SWC) and don't support `Object.values()`. Prefer string literal unions unless you have a specific reason for enum.

Common Union Patterns

Real-world union and intersection patterns you'll use in production TypeScript.

Result type (error handling without exceptions)
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: 'Division by zero' };
  return { ok: true, value: a / b };
}

const result = divide(10, 3);
if (result.ok) {
  console.log(result.value);  // number -- narrowed by discriminant
} else {
  console.log(result.error);  // string
}
Event map (typed event system)
type AppEvents = {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  error: { code: number; message: string };
};

type EventName = keyof AppEvents;

function emit<K extends EventName>(
  event: K,
  payload: AppEvents[K]
): void {
  // type-safe event emission
}

emit('login', { userId: '123', timestamp: Date.now() });
// emit('login', { wrong: true });  // Error
Branded types (nominal typing via intersection)
type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };

function usd(amount: number): USD { return amount as USD; }
function eur(amount: number): EUR { return amount as EUR; }

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const price = usd(10);
const tax = usd(2);
addUSD(price, tax);           // OK
// addUSD(price, eur(5));     // Error: EUR not assignable to USD

Branded types use an intersection with a phantom property to prevent accidental mixing of semantically different values that share the same underlying type.

Learn TypeScript in Depth
Union & Intersection →
Warm-up1 / 1

Can you write this from memory?

Declare `id` that can be string or number

See Also
Type Narrowing →Interface vs Type →

Start Practicing TypeScript

Free daily exercises with spaced repetition. No credit card required.

← Back to TypeScript Syntax Practice
Syntax Cache

Build syntax muscle memory with spaced repetition.

Product

  • Pricing
  • Our Method
  • Daily Practice
  • Design Patterns
  • Interview Prep

Resources

  • Blog
  • Compare
  • Cheat Sheets
  • Vibe Coding
  • Muscle Memory

Languages

  • Python
  • JavaScript
  • TypeScript
  • Rust
  • SQL
  • GDScript

Legal

  • Terms
  • Privacy
  • Contact

© 2026 Syntax Cache

Cancel anytime in 2 clicks. Keep access until the end of your billing period.

No refunds for partial billing periods.