Union Types

August 22, 202510 min read
Requirements:
FunctionsArraysObjects

When you first bump into TypeScript union types, they can feel like a strange hybrid tool. They are not classes, not enums, not quite interfaces, yet they solve a very common problem in real world JavaScript. This guide walks through the syntax, the four ways to narrow a union, the exhaustiveness trick that catches missing cases at compile time, and the gotchas that trip people up.

Why union types matter

JavaScript is loose about data. An ID might be a string in one place and a number in another. An API could return either a result object or an error. Without a way to model this, you are left juggling any, runtime checks, or duplicated functions. Union types let you say up front: this thing can be one of these specific types, nothing else. That gives you both safety and flexibility.

The payoff shows up at the call site. A function taking string | number documents its own contract, and the compiler refuses anything else before the code ever runs. You also stop writing defensive checks for cases that cannot happen, because the type already rules them out.

The basics: either or

A union type is written with a pipe character | between the options.

type UserID = number | string
 
let id: UserID
id = 42 // okay
id = 'alice' // also okay
// id = true;  // error

Here id is either a number or a string. TypeScript enforces that nothing outside those two slips in.

The pipe is the "or" operator of the type system. Its counterpart is &, which demands every member at once — see intersection types for that side of the coin.

Four ways to narrow TypeScript union types

Knowing something can be multiple types is only half the story. You often need to find out which one you actually have. This is called narrowing. TypeScript gives you four tools, and picking the right one depends on what the union is made of.

typeof for primitives

function formatInput(input: string | number) {
  if (typeof input === 'string') return input.toUpperCase()
  return input.toFixed(2)
}

typeof only sees JavaScript's runtime types, so it works for string, number, boolean, bigint, symbol, function, undefined and object. It cannot tell two interfaces apart — both are just 'object'.

in for object shapes

When the members are object types without a shared tag, check for a property that only one of them has.

interface Bird {
  layEggs(): void
  fly(): void
}
 
interface Fish {
  layEggs(): void
  swim(): void
}
 
type Pet = Bird | Fish
 
function handlePet(pet: Pet) {
  // Both Bird and Fish have layEggs, so this is safe
  pet.layEggs()
 
  // Only Fish has swim, so we need to check first
  if ('swim' in pet) {
    pet.swim() // TypeScript knows this is Fish
  } else {
    pet.fly() // TypeScript knows this is Bird
  }
}

instanceof for classes

If the union members are classes, instanceof narrows them directly.

class ApiError extends Error {
  constructor(public status: number) {
    super(`Request failed with ${status}`)
  }
}
 
function describeFailure(failure: ApiError | string) {
  if (failure instanceof ApiError) {
    return `HTTP ${failure.status}` // ✅ narrowed to ApiError
  }
  return failure // ✅ narrowed to string
}

This is the one to reach for with Error subclasses, Date, Map and anything else you construct with new.

Truthiness and equality

A plain truthiness check narrows away null and undefined.

function greet(name: string | null | undefined) {
  if (!name) return 'Hello, stranger'
  return `Hello, ${name}`
}

Careful here: '' is falsy too, so an empty string takes the stranger branch. When that matters, compare explicitly with name == null instead, which catches both null and undefined and nothing else.

Custom type guards

When a check gets reused, wrap it in a function whose return type is a type predicate — item is Book. TypeScript then trusts it everywhere.

interface Book {
  title: string
  isbn: string
}
 
interface Magazine {
  title: string
  issue: number
}
 
function isBook(item: Book | Magazine): item is Book {
  return 'isbn' in item
}
 
function shelfLabel(item: Book | Magazine) {
  if (isBook(item)) return `${item.title} (${item.isbn})`
  return `${item.title} #${item.issue}`
}

The predicate is a promise you make to the compiler, so the body has to be honest. Return the wrong answer and TypeScript will happily believe you.

Literal unions for fixed options

Sometimes you want to lock values to a small set of strings.

type Direction = 'north' | 'south' | 'east' | 'west'
let turn: Direction
turn = 'north' // valid
// turn = 'up'  // ❌ Type '"up"' is not assignable to type 'Direction'

This is a lightweight alternative to enums. Many developers prefer it because it adds no extra JavaScript output and works well with autocomplete.

This is also super useful for HTTP methods:

[object Object]

Literal unions multiply when you combine them. Template literal types take two small unions and build every combination for you:

type Lang = 'en' | 'de'
type Page = 'home' | 'about'
 
type Route = `/${Lang}/${Page}` // '/en/home' | '/en/about' | '/de/home' | '/de/about'

keyof gives you a union for free

Most developers meet their first union without writing a | at all. keyof on an object type produces a union of its keys.

interface Settings {
  theme: 'light' | 'dark'
  fontSize: number
  autosave: boolean
}
 
type SettingKey = keyof Settings // 'theme' | 'fontSize' | 'autosave'
 
function readSetting<K extends SettingKey>(
  settings: Settings,
  key: K,
): Settings[K] {
  return settings[key]
}

readSetting(config, 'fontSize') returns a number, and a typo in the key name is a compile error rather than an undefined at runtime. Add a field to Settings and the union grows with it — nothing to keep in sync by hand. This key union is also the engine behind mapped types, which iterate over it to build a new shape.

Uniting object types

Union types are not just for strings and numbers. They can describe complex structured data.

interface ErrorResult {
  success: false
  error: string
}
 
interface SuccessResult {
  success: true
  data: unknown
}
 
type Result = ErrorResult | SuccessResult

Here Result is either an error shape or a success shape. To safely use data, you first check result.success === true. That narrowing step avoids runtime surprises.

Discriminated unions: the tidy pattern

When you have several object shapes, give each one a shared literal field.

interface Circle {
  type: 'circle'
  radius: number
}
 
interface Square {
  type: 'square'
  sides: 4
}
 
interface Triangle {
  type: 'triangle'
  base: number
  height: number
}
 
type Shape = Circle | Square | Triangle

Then switch on shape.type and TypeScript narrows correctly in each branch.

function area(shape: Shape) {
  switch (shape.type) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'square':
      return shape.sides ** 2
    case 'triangle':
      return (shape.base * shape.height) / 2
  }
}

This is the workhorse pattern for modelling state, API responses and events. Discriminated unions go deeper on choosing the tag field and nesting them.

Exhaustiveness checks with never

The switch above handles all three shapes today. Add a fourth member next month and nothing stops you from forgetting a case — the function just quietly returns undefined.

never fixes that. It is the type with no values at all, so the only thing assignable to it is a union you have already narrowed to nothing.

function assertNever(value: never): never {
  throw new Error(`Unhandled union member: ${JSON.stringify(value)}`)
}
 
function describeShape(shape: Shape): string {
  switch (shape.type) {
    case 'circle':
      return `circle r=${shape.radius}`
    case 'square':
      return `square ${shape.sides}x${shape.sides}`
    case 'triangle':
      return `triangle ${shape.base}x${shape.height}`
    default:
      return assertNever(shape)
  }
}

In the default branch every member has been handled, so shape is narrowed to never and the call type-checks. Add a Hexagon to Shape and that branch now receives Hexagon, which is not assignable to never — you get a compile error pointing straight at the switch you forgot to update.

That is the whole trick: one helper turns "I hope I covered everything" into a guarantee the compiler enforces. It costs four lines once and pays off every time the union grows, which is exactly when a missed case is easiest to ship. Worth noting that assertNever throws at runtime as a last resort, so bad data coming in over the wire still fails loudly rather than silently.

Unions distribute

A union inside a generic does not stay in one piece. TypeScript applies the generic to each member and unions the results back together.

type ToArray<T> = T extends unknown ? T[] : never
 
type Lists = ToArray<string | number> // string[] | number[], not (string | number)[]

That distribution is why Exclude and Extract work at all — they are conditional types that keep or drop members one at a time.

[object Object]

Both live in the standard library alongside the rest of the utility types.

The same distinction matters for plain arrays, and the two are easy to mix up:

type MixedList = (string | number)[] // one array, each item may be either
type SeparateLists = string[] | number[] // all strings, or all numbers

Object literals and excess property checks

Assigning an object literal to a union is stricter than assigning a variable, and the error message rarely makes that obvious.

interface Dog {
  kind: 'dog'
  barkVolume: number
}
 
interface Cat {
  kind: 'cat'
  livesLeft: number
}
 
type Animal = Dog | Cat
 
const rex: Animal = {
  kind: 'dog',
  barkVolume: 11,
  // livesLeft: 9, // ❌ excess property check: not on Dog
}

A literal has to match one member cleanly. Go through a variable first and the check does not apply:

const draft = { kind: 'cat' as const, livesLeft: 9, barkVolume: 0 }
const mittens: Animal = draft // ✅ extra property is allowed here

Neither behaviour is a bug, but knowing which one you are in saves a lot of staring at an error about a property you deliberately added.

Pitfalls to avoid

Union types versus enums and any

Enums give you runtime objects, unions are compile time only. Many developers prefer literal unions for their simplicity. A literal union also stays open to refactoring: adding a member is one character, and every exhaustive switch in the codebase lights up red until you handle it. Compared to any, unions are strict about what is allowed. They push you to handle all real possibilities, instead of leaving silent holes that turn into runtime errors. Reach for an enum only when you genuinely need the runtime object — a reverse lookup, or a value you pass across a module boundary at runtime.

Wrapping up

Union types are one of the most practical features in TypeScript. They let you capture the uncertainty of real world data without falling back on any.

Pair them with the right narrowing tool — typeof for primitives, in for shapes, instanceof for classes, a type predicate when the check repeats — and add an assertNever default to every switch you write over a discriminated union. That combination catches the mistakes in your editor instead of in production.

Share this article

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your union types knowledge to the test with these related challenges.

#43Exclude
Easy
#14First of Array
Easy
#10Tuple to Union
Medium
#531String to Union
Medium
#296Permutation
Medium
#1097IsUnion
Medium

Related Concepts

Concepts that build on or relate to union types.

Intersection TypesTypeScript GenericsTypeScript Enums

Common Errors

TypeScript errors you might encounter when working with union types.

TS2322Fix TS2322: Type Not AssignableTS2339Fix TS2339: Property Does Not Exist on TypeTS2344Fix TS2344: Type Does Not Satisfy the ConstraintTS2349Fix TS2349: This Expression Is Not CallableTS2367Fix TS2367: Comparison Appears to Be UnintentionalTS2454Fix TS2454: Variable Is Used Before Being AssignedTS2531Fix TS2531: Object Is Possibly 'null'TS2532Fix TS2532: Object Is Possibly 'undefined'