Understanding Discriminated Unions in Typescript

Authors
Posted on
Posted on

As you may already know, Typescript is a superset of Javascript; and is simply awesome. There are numerous features we love and hear about Typescript every day. For example, we could have a look at interfaces, enums, unions, intersections, type guards and much more.

In this article, we're going to focus on Discriminated Unions in TypeScript. We'll look into what they are and by the end of the article, you'll have a good understanding of where you can use these.

Discriminated Unions

Discriminated Unions, also called algebraic data types or tagged unions are a combination of three things:

  • The discriminant
  • The union
  • Type guards

Let's understand each of the above, one by one with examples.

The Discriminant

The discriminant is a singleton type property which is common in each of the elements of the union. You can read more about Typescript Singleton Types in this article.

See the example below:

enum CarTransmission {
  Automatic = 200,
  Manual = 300,
}

interface IMotorcycle {
  vType: 'motorcycle' // discriminant
  make: number // year
}

interface ICar {
  vType: 'car' // discriminant
  transmission: CarTransmission
}

interface ITruck {
  vType: 'truck' // discriminant
  capacity: number // in tons
}

You can see that the vType property in the interfaces is the discriminant or the tag. The other properties are specific to the corresponding interfaces.

The Union

The union of the interfaces can be simply created as follows:

type Vehicle = IMotorcycle | ICar | ITruck

We can now use this union (type) in our code where we can have more than one kind of vehicles expected in a variable.

The Type Guards

Consider the following example based on the interfaces we defined above:

const evaluationFactor = Math.PI // some global factor

function evaluatePrice(vehicle: Vehicle) {
  return vehicle.capacity * evaluationFactor
}

const myTruck: ITruck = { vType: 'truck', capacity: 9.5 }
evaluatePrice(myTruck)

The above code will cause the typescript compiler to throw the following error:

Property 'capacity' does not exist on type 'Vehicle'.
  Property 'capacity' does not exist on type 'IMotorcycle'.

The reason is that the property capacity does not exist on the interface IMotorCycle. Well, actually it doesn't exist in ICar too but it already breaks checking IMotorCycle, which is declared before ICar, so it doesn't reach checking ICar.

Well, how do we fix this? Using type guards of course. See an example below:

function evaluatePrice(vehicle: Vehicle) {
  switch (vehicle.vType) {
    case 'car':
      return vehicle.transmission * evaluationFactor
    case 'truck':
      return vehicle.capacity * evaluationFactor
    case 'motorcycle':
      return vehicle.make * evaluationFactor
  }
}

Using the switch & case operators fix the problem for us by serving as type guards, making sure we're accessing the right properties of the vehicle that we've got in the evaluatePrice method.

If you're using an editor like VSCode, you'll notice that before using these type guards, the IntelliSense may only have shown you vType as a property when you typed 'vehicle.'. But if you type 'vehicle.' inside any of the case statements now, you'll see that now, the appropriate properties by the IntelliSense are shown from the appropriate interfaces.

Checking Exhaustiveness

What if we wanted to introduce a new type/interface to the union Vehicle? You might think that the evaluatePrice function doesn't have the case handled for that. And that's accurate. But we need the compiler to let us know at build time (or using tslint etc) that we need to cover all variants of the type zVehiclez. This is called Exhaustiveness Checking. One of the ways to ensure we're covering all variants of a union is to use never, which the typescript compiler uses for exhaustiveness.

Assume we added a new type IBicycle to the Vehicle union as below:

interface IBicycle {
  vType: 'bicycle'
  make: number
}

type Vehicle = IMotorcycle | ICar | ITruck | IBicycle

We'll be able to use never for the exhaustiveness check as follows:

function evaluatePrice(vehicle: Vehicle) {
  switch(vehicle.vType) {
    case "car":
      return vehicle.transmission * evaluationFactor;
    case "truck":
      return vehicle.capacity * evaluationFactor;
    case "motorcycle":
      return vehicle.make * evaluationFactor;
    default:
      const invalidVehicle: never = vehicle;
      return throw new Error(`Unknown vehicle: ${invalidVehicle}`);
  }
}

The above should show an error in the editor (using lint tools) or on compile time as below:

Type 'IBicycle' is not assignable to type 'never'.

The above shows we need to handle IBicycle as well. Once we add the case for IBicycle in the evaluatePrice method as below, the error should go away.

function evaluatePrice(vehicle: Vehicle) {
  switch(vehicle.vType) {
    case "car":
      return vehicle.transmission * evaluationFactor;
    case "truck":
      return vehicle.capacity * evaluationFactor;
    case "motorcycle":
      return vehicle.make * evaluationFactor;
    case "bicycle":
      return vehicle.make * evaluationFactor;
    default:
      const invalidVehicle: never = vehicle;
      return throw new Error(`Unknown vehicle: ${invalidVehicle}`);
  }
}

You can find a working example here on Stackblitz.

Conclusion

Discriminated unions are pretty powerful combined with Typescript's ability to differentiate the types based on the discriminants/tags. When used right, this can bring significant readability to the code and is great when it comes to writing reliable dynamic types with functions.

Further Reading

Exhaustive Type Checking with TypeScript! Advanced Types - Typescript CodingBlast Typescript series

If you learned something new from this article, don't forget to show this to your friends and workmates. Happy coding!