Union Types in TypeScript
When you first bump into Union Types in TypeScript, 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.
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 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.
Narrowing down with type guards
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.
- Use
typeof
for primitives:
function formatInput(input: string | number) {
if (typeof input === 'string') return input.toUpperCase()
return input.toFixed(2)
}
- Use
in
for object shapes. If both Bird and Fish havelayEggs
, but only Fish hasswim
, you can check"swim" in pet
.
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
}
}
- Write custom guards. A helper like
function isBook(item): item is Book
makes checks reusable.
Think of it like a Swiss Army knife. The handle could be many tools, but you check which blade is open before using it.
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' // error
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:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
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, add type
field to each.
interface Circle {
type: 'circle'
radius: number
}
interface Square {
type: 'square'
sides: 4
}
type Shape = Circle | Square | Triangle
Then switch on shape.type
. TypeScript will narrow correctly in each branch.
If you later add a Triangle, the compiler will complain until you handle it.
interface Triangle {
type: 'triangle'
sides: 3
}
You can even add a helper like assertNever
to guarantee every case is covered.
function area(shape: Shape) {
switch (shape.type) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.sides ** 2
// compiler will complain if you add a new shape without handling it
}
}
Pitfalls to avoid
- Using union values without narrowing. Accessing
.length
onstring | number
will 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 trick.
- Falling back to
any
. Reach for unions first; they keep you honest.
Union types versus enum
s and any
Enums give you runtime objects, unions are compile time only. Many developers prefer literal unions for their simplicity.
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.
A micro utility
If you often use discriminated unions, keep this helper:
function assertNever(x: never): never {
throw new Error('Unexpected value: ' + x)
}
Drop it into a default case in a switch, and TypeScript will yell at you if you missed a new variant. It is a tiny safeguard that scales in large codebases.
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
.
Learn to pair them with narrowing, literal unions, and discriminated unions.
With a few small patterns like assertNever
, you will catch mistakes at compile time instead of production.
Where could you apply a union in your own code this week? Maybe an API response, maybe a function that handles multiple input forms. Try one and see how much clearer your types become. And if you run into tricky cases, I would love to hear your stories.
Stay Updated
Get the latest TypeScript tips, tutorials, and course updates delivered straight to your inbox.