Union Types
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; // errorHere 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 | SuccessResultHere 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 | TriangleThen 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 numbersObject 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 hereNeither 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
- Using union values without narrowing. Accessing
.lengthonstring | numberwill fail if it happens to be a number. - Making unions too broad. A dozen options might suggest a better pattern. Check out Generics for a better hint.
- Forgetting to update cases. Always use the exhaustiveness check.
- Unioning function signatures. A
((a: string) => void) | ((a: number) => void)is callable only withstring & number, which isnever. Take a union parameter instead — see function types. - Falling back to
any. Reach for unions first; they keep you honest.
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.
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