The Discriminated Union: Writing Easy-to-Use Types in TypeScript

TypeScript’s flexibility is one of the reasons I love using it. Unfortunately, that flexibility also provides opportunities to write confusing code. The main area where I see developers struggling is defining types that make writing code painful or verbose. Often, this comes in the form of using optional fields to extend types so they represent too many ideas within the same type.

An Example

Take this, for example:


type Vehicle = {
  model: string;
  engineCC: number;
  // car specific field, not always used
  cupholders?: number;
  // motorcycle specific field, not always used
  handlebarWidth?: number;
}

This doesn’t look so bad at first glance. But, consider that, in a real application, a central domain model like this might have two dozen fields. If half of those fields are optional because they are only available on some subtype, the application logic quickly becomes a confusing mess of null checks.

In many places in our application where this type gets used, we must now do several checks to determine if the vehicle is a car or a motorcycle. And, even if we are 100% sure the vehicle is a car, we still need to introduce a null coalescing operator if we use the cupholder field to satisfy typescript in many cases. That’s because type string is not equivalent to type string | undefined.

Using a Discriminated Union

So we want some way of representing multiple subtypes without introducing heavyweight object-oriented patterns like abstract classes. Enter the discriminated union:


type Vehicle = {
  kind: 'car' | 'motorcycle';
  model: string;
  engineCC: string
} & (Car | Motorcycle)

type Car = {
  kind: 'car';
  cupholders: number;
}

type Motorcycle = {
  kind: 'motorcycle';
  handlebarWidth: number;
}

These string-literal type fields provide us with the best of all worlds when working with different kinds of the same type. When we don’t care what kind of vehicle we’re using, we can ignore these kind-specific fields. Otherwise, narrowing the type is just one logical statement away.


const wat: Vehicle = {
  // ...
}

if (wat.kind === 'car') {
  // type error
  // wat.handlebarWidth

  const cupholderCount = wat.cupholders;
  // do something with all those cupholders
}

This is just one of several handy tricks you can access once you’re living in the land of discriminated unions. Let’s take a look at my current favorite way to use this powerful tool: versioned types in service of a migratable noSQL database.

Discriminated Unions Applied to noSQL

Many developers choose noSQL due its simplicty when it comes to translating application domain types into database types. There’s no requirement to map application data into SQL rows. That eliminates the need for complex and hard-to-debug Object Relational Mappers. This is a great feature for many different use cases. Unfortunately, many developers fall for this siren song in the wrong situations. They end up modeling business-critical relational data in noSQL in an undisciplined manner. This leaves them in a quandary. How does one ensure the database has accurate, well-typed, and migratable data when there’s no first-class schema definition?

You already know that my answer is the discriminated union. Without further ado let’s walk through an example of this pattern using our vehicle type from earlier, stripped down to keep the example simple:


type VehicleV0 = {
  schemaVersion: 0;
  model: string;
  engineCC: number;
}

export type Vehicle = VehicleV0

We’ve gone ahead and added a schemaVersion field. This gets stored in the noSQL database along with everything else. Let’s say that the car business wants to start measuring engine CC in both liters and cubic centimeters. Our database will currently not support that behavior. We need to add a field for units. Instead of relying on complex application logic to figure out how to apply this noSQL schema change, we can document and rely on schemaVersion to apply migrations in a consistent, well-typed fashion.


type VehicleV1 = {
  schemaVersion: 1;
  model: string;
  engineSize: number;
  engineSizeUnits: 'CC' | 'L';
}

export type Vehicle = VehicleV0 | VehicleV1

Now our vehicle type can handle either the migrated new information or the old, unmigrated version without relying on optional fields. All of this complexity can be hidden from consumers of the database service. Meanwhile, the service itself can work with any version of the noSQL schema in a type-safe environment. This makes migrations significantly easier and safer to write.

Caveat

One caveat to this approach is if you find yourself at the outset of a project writing complex relational migrations or modeling highly interconnected domain models. In this case, you might want to consider dropping noSQL altogether and sticking to a relational database unless you have a specific need to use noSQL for other reasons.

These are just a few examples of this powerful tool in the TypeScript tool belt. Even if you don’t have these kinds of issues currently in your code, I’d encourage you to give discriminated unions a shot next time you find yourself needing to model subtypes. They certainly aren’t a one-size-fits-all solution but there’s a lot of learn about how TypeScript handles types hiding under the surface that can help you hone your TypeScript skills.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *