Discriminated Unions in TypeScript
Maikelev

Maikelev @maikelev

About: Senior Web Developer with over 10 years of experience in front-end technologies, specializing in building responsive and user-friendly websites and web applications.

Joined:
Jun 10, 2025

Discriminated Unions in TypeScript

Publish Date: Jun 13
2 1

We'll explore three ways to create a discriminated union in TypeScript and the benefits it brings to our code.

What is a discriminated union?

Discriminated unions are a technique in TypeScript for creating a union of types that share a common property (the discriminator), whose literal value allows TypeScript to know exactly which type to use.

In this tutorial, the discriminator will be the type property, although it can have any name as long as it's the same in all the types.

The discriminator property must be a literal type, such as a string literal, number literal, or boolean literal.

Let's say we have these two interfaces:

interface Person {
  type: "Person"
  roles: ("engineer" | "technician" | "supervisor")[]
}

interface Robot {
  type: "Robot"
  task: "packaging" | "transport" | "maintenance"
}
Enter fullscreen mode Exit fullscreen mode

I. Intersection with common fields

type Entity = { name: string } & (Person | Robot)
Enter fullscreen mode Exit fullscreen mode

This is useful when the interfaces come from elsewhere (for example, generated automatically or imported from another system) and you can't modify them, but you want to work with common properties. It also applies if the common property doesn't have a direct logical relationship with the specific types.

This option may not be very clear at first glance, and the IDE might not provide much assistance when working with it.

II. Using a base interface

interface BaseEntity {
  name: string
}

interface Person extends BaseEntity {
  type: "Person"
  roles: ("engineer" | "technician" | "supervisor")[]
}

interface Robot extends BaseEntity {
  type: "Robot"
  task: "packaging" | "transport" | "maintenance"
}

type Entity = Person | Robot
Enter fullscreen mode Exit fullscreen mode

TypeScript provides better autocomplete when using extends, and the IDE offers a better development experience.

III. Extensible record (map or dictionary)

type EntityMap = {
  Person: Person
  Robot: Robot
}

type Entity = EntityMap[keyof EntityMap]
Enter fullscreen mode Exit fullscreen mode

Example of usage

const handlers: { [K in keyof EntityMap]: (entity: EntityMap[K]) => void } = {
  Person: (e) => console.log(e.roles),
  Robot: (e) => console.log(e.task),
}
Enter fullscreen mode Exit fullscreen mode

Here, the type and its corresponding function are automatically linked, and TypeScript will show an error if you forget to handle a case.

You can also create computed types based on the keys:

type EntityTypes = keyof EntityMap // 'Person' | 'Robot'
type EntityOf<T extends EntityTypes> = EntityMap[T]
Enter fullscreen mode Exit fullscreen mode

This approach is useful when you want to associate metadata with each type. For example, having an object that maps type → render function or associating a type with specific configuration.

Contextual Typing with Discriminated Unions

TypeScript can accurately infer the subtype (Person or Robot) during the literal array construction because:

  • The objects have a literal type ('Person' or 'Robot').
  • The properties exactly match one of the subtypes.
  • The context (Array) helps TypeScript resolve the inference.

This is called contextual typing with discriminated unions.

const entityList: Array<Entity> = [
  {
    type: "Person",
    name: "Person 1",
    roles: ["engineer", "technician"],
    // (property) Person.roles: ("engineer" | "technician" | "supervisor")[]
  },
  {
    type: "Person",
    name: "Person 2",
    roles: ["technician"],
  },
  {
    type: "Robot",
    name: "Robot 1",
    task: "transport",
    // (property) Robot.task: "packaging" | "transport" | "maintenance"
  },
]
Enter fullscreen mode Exit fullscreen mode

What Happens Outside the Literal Context?

Outside the immediate literal context, type inference is lost.

TypeScript doesn't know which subtype (Person or Robot) you're working with, because entity is of the base type Entity (it only knows about name and type).

entityList.forEach((entity) => {
  entity.roles // ❌ Error: Property 'roles' does not exist on type 'Entity'.

  if (entity.type === "Person") {
    entity.roles // ✅ Now it works thanks to discriminated unions.
  }
})
Enter fullscreen mode Exit fullscreen mode

Narrowing

We can also use narrowing techniques, such as runtime checks (conditions).

TypeScript narrows the type of a variable within a condition by using the value of the discriminator (type).

TypeScript automatically uses the type property to infer which subtype you're working with, providing complete type safety without needing to use type assertions.

function handleEntity(entity: Entity) {
  switch (entity.type) {
    case "Person":
      console.log(entity.roles)
      break
    case "Robot":
      console.log(entity.task)
      break
  }
}
Enter fullscreen mode Exit fullscreen mode

Exhaustiveness Checking

Another important use case is ensuring that the compiler warns us if we forget to handle one of the variants in a discriminated union. For example, if we later add a new entity like Alien:

interface Alien extends BaseEntity {
  type: "Alien"
  planet: string
}
Enter fullscreen mode Exit fullscreen mode

To solve this, TypeScript can help us ensure that all possible cases are handled using exhaustiveness checking.

Here are two ways to do this:

I. Enable strictNullChecks and enforce a return type
If we enable strictNullChecks in the compiler options and define an explicit return type (e.g., string), TypeScript will throw an error if a branch does not return a value:

{
  "compilerOptions": {
    "strictNullChecks": true
  }
}
Enter fullscreen mode Exit fullscreen mode
function handleEntity(entity: Entity): string {
  // ❌ Error: Argument of type 'Alien' is not assignable to parameter of type 'never'.
  switch (entity.type) {
    case "Person":
      console.log(entity.roles)
      break
    case "Robot":
      console.log(entity.task)
      break
  }
}
Enter fullscreen mode Exit fullscreen mode

This forces us to either handle all possible cases or provide a fallback.

II. Using never for Exhaustiveness Checking

Another, more explicit, way is to use the never type, which the compiler uses to ensure that no cases are left unhandled.

function assertNever(x: never): never {
  throw new Error("Unexpected object: " + x)
}
Enter fullscreen mode Exit fullscreen mode

With this approach, the compiler checks that after handling all known cases, nothing remains. If a case is forgotten, TypeScript throws an error because the type won't be never.

function handleEntity(entity: Entity): string {
  switch (entity.type) {
    case "Person":
      console.log(entity.roles)
      break
    case "Robot":
      console.log(entity.task)
      break
    default:
      return assertNever(entity)
    // ❌ ERROR: Argument of type 'Alien' is not assignable to parameter of type 'never'.
  }
}
Enter fullscreen mode Exit fullscreen mode

This approach requires an extra helper function but makes it much clearer when a case is missing, as the error will include the name of the missing type.

Conclusion

For simple use cases, a base interface works well. When you need more flexibility, like associating handlers or metadata with each type, consider using a mapped type.

Comments 1 total

  • Admin
    AdminJun 13, 2025

    Big announcement for our Dev.to authors: Dev.to is distributing DEV Contributor rewards in recognition of your efforts on Dev.to. Claim your rewards here. wallet connection required. – Admin

Add comment