TypeScript Discriminated Unions
A discriminated union is a union of object types where every member carries the same property, and that property holds a different literal value in each member. Check the property, and TypeScript knows exactly which member you are holding.
That is the whole idea. It sounds almost too small to have a name, but it is the pattern that turns "this object might have some of these fields" into "this object is exactly one of these things" — and that difference is where most of the runtime bugs live.
The problem it solves
Here is the shape you write when you have not reached for a discriminated union yet. One
interface, a kind string, and every field the various cases might need marked optional.
interface LooseShape {
kind: string
radius?: number
side?: number
}
function looseArea(shape: LooseShape): number {
if (shape.kind === 'circle') {
// radius is number | undefined, so this does not compile without a check
return Math.PI * (shape.radius ?? 0) ** 2
}
return (shape.side ?? 0) ** 2
}Everything about this is a little bit wrong. kind is string, so 'cirlce' typos past
the compiler. radius and side are optional on every shape, so you are forced into
?? 0 fallbacks that quietly return a wrong answer instead of failing. And nothing stops
you from constructing { kind: 'circle', side: 4 }, which is nonsense.
The fallbacks are the tell. When you find yourself defaulting a value that should always be there, the type is describing a union badly.
Adding the discriminant
Split the cases into their own types and give each one a literal tag.
interface Circle {
kind: 'circle'
radius: number
}
interface Square {
kind: 'square'
side: number
}
interface Rectangle {
kind: 'rectangle'
width: number
height: number
}
type Shape = Circle | Square | Rectanglekind: 'circle' is a literal type — not "some string", but that
exact string and nothing else. Because the three literals are different, TypeScript can
tell the members apart just by looking at kind. That property is the discriminant.
Now every field is required where it belongs and absent where it does not. There is no way
to build a circle with a side, and no fallback to write.
Narrowing with switch
Switch on the discriminant and each branch gets the full, specific type.
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.side ** 2
case 'rectangle':
return shape.width * shape.height
}
}Inside case 'circle', shape is a Circle. Not Shape, not "probably a circle" —
Circle, with radius as a plain number. Reach for shape.side in that branch and the
compiler stops you.
if works exactly the same way, which matters when there are only two cases.
function describe(shape: Shape): string {
if (shape.kind === 'circle') {
return `A circle of radius ${shape.radius}`
}
return `A ${shape.kind} with straight edges`
}After the early return, shape is Square | Rectangle. TypeScript subtracts the handled
member from the union as you go.
Exhaustiveness: the part that pays for itself
Narrowing is nice. Exhaustiveness checking is the reason this pattern is worth adopting across a codebase.
Add a helper that only accepts never:
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`)
}
function perimeter(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return 2 * Math.PI * shape.radius
case 'square':
return shape.side * 4
case 'rectangle':
return (shape.width + shape.height) * 2
default:
return assertNever(shape)
}
}In the default branch every member has been handled, so shape has narrowed all the way
down to never — and never is the only thing assertNever accepts. Today this compiles
and the default is dead code.
Tomorrow you add a variant:
interface Triangle {
kind: 'triangle'
base: number
height: number
}
type ShapeOrTriangle = Shape | Triangle
// Point `perimeter` at ShapeOrTriangle and the default branch breaks:
//
// default:
// return assertNever(shape)
// ^^^^^
// ❌ Argument of type 'Triangle' is not assignable to parameter of type 'never'Every switch with an assertNever default now fails to compile until you handle triangles.
The compiler hands you the list of places to update instead of leaving you to find them
after a user does. That is the trade: one tiny helper, and adding a case becomes a
compile-time task rather than a grep.
What makes a good discriminant
The discriminant has to be a literal type present on every member. Miss either condition and the narrowing quietly stops working.
Strings are the common choice, but numbers and booleans work just as well — useful for versioned payloads and for flags where one state carries extra data.
interface V1Payload {
version: 1
body: string
}
interface V2Payload {
version: 2
body: { text: string }
}
type Payload = V1Payload | V2Payload
function readBody(payload: Payload): string {
return payload.version === 1 ? payload.body : payload.body.text
}
type ToggleOff = { enabled: false }
type ToggleOn = { enabled: true; level: number }
type Toggle = ToggleOff | ToggleOn
function levelOf(toggle: Toggle): number {
return toggle.enabled ? toggle.level : 0
}Note that body changes type between the two versions. That is fine. Only the discriminant
needs to be a literal; the rest of each member can differ however you like.
The inference trap
One thing catches nearly everyone. Object literals assigned to a let or const widen
their string properties to string, which destroys the discriminant.
const wide = { kind: 'circle', radius: 5 }
// area(wide)
// ❌ Type 'string' is not assignable to type '"circle" | "square" | "rectangle"'
const narrowed = { kind: 'circle', radius: 5 } satisfies Shape
area(narrowed) // ✅wide.kind is string, because TypeScript has no reason to think you meant the literal.
satisfies Shape gives it that reason: the object is checked against Shape, the literal
is preserved, and you still get the precise { kind: 'circle'; radius: number } type back.
as const works too, and passing the literal straight into a function call needs neither —
the parameter type provides the context.
Where you will actually use this
Two shapes come up constantly. The first is a result type, which replaces "returns the data and maybe throws" with something the caller cannot ignore.
type Ok<T> = { status: 'ok'; data: T }
type Err = { status: 'error'; code: number; message: string }
type Result<T> = Ok<T> | Err
function unwrap<T>(result: Result<T>): T {
if (result.status === 'error') {
throw new Error(`${result.code}: ${result.message}`)
}
return result.data
}The second is request state. If you have ever rendered a spinner next to a stale error
message, it is because loading, error and data were three independent fields instead
of one union.
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; users: string[] }
| { status: 'failed'; error: string }
function renderState(state: RequestState): string {
switch (state.status) {
case 'idle':
return 'Nothing requested yet'
case 'loading':
return 'Loading…'
case 'success':
return `${state.users.length} users`
case 'failed':
return `Something broke: ${state.error}`
}
}Four states, four branches, and no way to be loading and failed at once. The impossible combinations stop being something you remember to avoid and start being something you cannot write.
Picking members back out
Once a union is tagged, the tag is also a handle for selecting members at the type level.
Extract and Exclude match against it directly, so you can name a subset without
repeating the definitions.
type Straight = Exclude<Shape, Circle>
type JustSquare = Extract<Shape, { kind: 'square' }>
function widthOf(shape: Straight): number {
return shape.kind === 'square' ? shape.side : shape.width
}Straight is Square | Rectangle, and it stays correct when you add a member — nothing
here lists the variants by hand.
The same tag works at runtime when you need to filter a collection. A plain .filter()
returns Shape[], because TypeScript does not follow the predicate into the callback. A
type predicate closes that gap.
function circlesOnly(shapes: Shape[]): Circle[] {
return shapes.filter((shape): shape is Circle => shape.kind === 'circle')
}The shape is Circle return type is a promise you are making to the compiler, so keep the
check and the predicate in sync — TypeScript takes your word for it here rather than
verifying. Comparing the discriminant is about as safe as that promise gets.
Pitfalls
- A discriminant missing from one member. If even one member lacks the property, you
cannot read it off the union at all —
Property 'kind' does not exist on type .... Fall back to theinoperator or, better, add the tag.
interface Dog {
kind: 'dog'
bark(): void
}
interface Cat {
meow(): void
}
type Pet = Dog | Cat
function speak(pet: Pet): void {
// pet.kind ❌ Property 'kind' does not exist on type 'Pet'
if ('bark' in pet) pet.bark()
else pet.meow()
}- Widening the tag to
string. Covered above, and it is almost always the cause when narrowing "just stops working". - Reusing the same literal twice. Two members tagged
'error'are indistinguishable, and narrowing gives you the union of both. - Skipping the
assertNeverdefault. Without it, a missing case is a silentundefinedat runtime rather than a compile error. This is the whole payoff — take it. - Destructuring the discriminant early.
const { kind, radius } = shapepulls the tag out of the object, and checking the loosekindvariable afterwards narrows nothing. Checkshape.kindfirst, destructure inside the branch.
Discriminated unions or enums?
You can use an enum member as the discriminant, and it narrows fine. Most codebases still reach for string literals: they emit no JavaScript, they read the same in the debugger as in the source, and JSON coming off the wire already matches them without a conversion step.
Enums earn their place when the tag is shared across many files and you want one canonical
list to import. Otherwise the literal union is less machinery for the same safety, and you
keep the option of deriving the list of tags from the union itself with
Shape['kind'] rather than maintaining it separately.
Practice this
The pattern clicks faster once you have manipulated unions at the type level. A few challenges that build the right intuition:
- String to Union — build a union from scratch, the gentlest starting point
- Tuple to Union — see how union members are produced and enumerated
- Union to Intersection — how unions distribute over conditional types, which is the machinery behind narrowing
- Replace Union — swap a member out, the type-level version of adding a variant
- Union to Object from key — key an object by a discriminant, the tag-to-payload lookup written as a type
Wrapping up
A discriminated union is a plain union plus a literal tag on every member. The tag lets
TypeScript narrow with switch or if, and pairing that with an assertNever default
turns every new variant into a compile error at exactly the places that need updating.
If you have a type with three optional fields and a comment explaining which combinations
are legal, that comment is a discriminated union waiting to be written. Convert one this
week — the ?? 0 fallbacks disappear and take a class of bugs with them.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges