The case for Discriminated Union Types with Typescript

Alejandro Dustet

Typescript

It is quite common when modeling a real-life problem to have constraints such as having a user account with either a nickname or an email, an avatar or an emoji. TypeScript gives us tools to model this kind of data.

What options do we have?

type Boss = {
  president?: Official;
  king?: Monarch;
}

type Official = {
  name: string,
  age: number,
}

type Monarch = {
  name: string,
  title: string,
}

This example adds two optional properties to describe that situation. Notice that to display a Boss’s name, we have to check for the presence of at least one attribute. A boss object might have either a president or a king, but the shape of the data does not fit that description. Having n optional properties isn’t the best way to describe the presence of at least one of them.

The Boss type aims to represent it can be either an official or a monarch. Having both optional properties opens up to many invalid states. Such as the possibility of having none of the attributes, or having them all at the same time.

If we model a solution with multiple optional properties, navigating the codebase would come with multiple null object checks.

const bossName({ president, king }: Boss): string => {
  if(president) {
     return president.name;
  } else if(king) {
    return king.name;
  }
  // Still the type definitions allows a case where both properties might
  // be null so we must plan for this
  else {
    return "Apparently total anarchy";
  }
}

How might we avoid this mismatch between the state of the problem and our domain modeling? Typescript allows us to combine different types using the union operator(|). When used along with type aliases to define a new type is known as union types.

type Boss = Official | Monarch;

const bossName = (boss: Boss): string => {
  return boss.name;
}

This works particularly well here since both types have the same property.

Type guards πŸ’‚β€β™€οΈ

What about when we have to reach for other attributes? The check for nullability becomes a type assertion.

const bossDescription = (boss: Boss): string => {
  if((boss as Official).age) {
    const official = (boss as Official);
    return `${official.name}, ${official.age} years old`;
  } else if((boss as Monarch).title) {
    const monarch = (boss as Monarch);
    return `${monarch.title}, ${monarch.name}`;
  } else {
    return 'The anarchy continues';
  }
}

The as operator instructs the compiler to treat the variable to the left as the type to the right. In this particular example, we are converting an argument of type Boss into an Official first and later into a Monarch. One thing to notice with this operand is that we are using it twice in the if block. First, to assert that we are dealing with an Official and then to access the age property in the object. Given that we guarded that branch feels unnecessary to specify again that we are in the presence of an instance of an object of type Official.

The final type assertion feels unnecessary since there should be only two possibilities. Since we are telling the compiler to trust us on this one(by using type assertions), we need to keep guiding it after that.

More type guards

There are other ways to check the type: one of them is using type predicates with the is operand.

const isMonarch(boss: Boss) boss is Monarch {
  return (boss as Monarch).title !== undefined;
}

This approach works, but it feels like we are doing the compiler’s job, the compiler is probably better at figuring this out than we are. As long as we stick to annotating the types from our queries, using type guards feels more robust.

In you go

Another tool at our disposal is the in operator. We can assert the presence of the property in our instance before using it.

const bossDeescription = (boss: Boss): string => {
  if ("age" in boss) {
    return `${boss.name}, ${boss.age} years old`;
  }
  return `${boss.title}, ${boss.name}`;
}

Using the in operator confirms that the properties we check are present and non-optional. However, the opposite is true in both counts. After this predicate, the remaining type either does not have the checked property, or it is undefined.

One downside to this alternative is that the action of picking the right property involves insider knowledge about the type. Using a property that was added to describe the object itself, to differentiate two types is far from ideal. There’s gotta be a better way!

joey

Introducing Discriminated Unions:

A solution is using a combination of string literal types, union types, type guards, and type aliases. To discriminate one type from the other in a union, we add a __typename property for that goal. Since it’s purpose is to differentiate, the value should be a constant. In the following example we use a string literal or singleton type:

type Official = {
  __typename: 'Official',
  name: string,
  age: number,
}

type Monarch = {
  __typename: 'Monarch',
  name: string,
  title: string,
}

type Boss = Official | Monarch;

const bossDescription = (boss: Boss): string => {
  if (boss.__typename === 'Official') {
    return `${boss.name}, ${boss.age} years old`;
  }
  return `${boss.title}, ${boss.name}`;
}

Now there is a straightforward and self-composed way of distinguishing the types on our aliased union. Without having to peek into the type definition and not leaving us exposed to future side effects from valid changes.

Conclusion

When we model a problem that has property A or property B Typescript has an impressive array of options to solve the problem. Making sure we evaluate the options is our part. By using the in operator, we are relying on attributes added with a purpose far from differentiating this type from the others. When using a random attribute from a type that was not added to make it unique, we are violating the contract agreed upon by consuming it. The as operator demands to import all of the type definitions just for making the assertion adding unnecessary dependencies to our code.

Discriminated Unions combine more than one technique and create self-contained types. Types that carry all the information to use them without worrying about name collisions or unexpected changes.