TypeScript Discriminated Unions

August 31, 202610 min read
Requirements:
| UnionsObjectsFunctions

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 | Rectangle

kind: '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

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()
}

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:

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.

Share this article

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

Practice with Challenges

Put your typescript discriminated unions knowledge to the test with these related challenges.

#531String to Union
Medium
#10Tuple to Union
Medium
#13580Replace Union
Hard
#55Union to Intersection
Hard
#33763Union to Object from key
Hard

Related Concepts

Concepts that build on or relate to typescript discriminated unions.

Union TypesInterfacesTypeScript EnumsTypeScript typeof