Exhaustiveness Checking In Typescript Typescript

Feb 11th, 2022 - written by Kimserey with .

Last week we talked about type predicate allowing us to narrow down based a variable based on a predicate function. In today’s post, we’ll continue to explore some of the narrowing functionalities of Typescript by looking at discriminated union narrowing and exhaustiveness checking.

Discriminated Union Narrowing

When we have a discriminated union with multiple interfaces:

1
2
3
4
5
6
7
8
9
10
11
12
interface Apartment {
  type: "apartment";
  address: string;
  level: number;
}

interface House {
  type: "house";
  address: string;
}

type Home = Apartment | House;

we can make sure that the interfaces have a single property differentiating them between each other within the union, here we use type which would be of type "apartment" | "house".

Using type, we ca then narrow the types with a control flow statement like if/else or switch/case:

1
2
3
4
5
6
7
8
9
10
function getFullAddress(home: Home) {
  switch (home.type) {
    case "apartment":
      // home is of type Apartment
      return `${home.address}, level ${home.level}`;
    case "house":
      // home is of type House
      return home.address;
  }
}

Within the case statements, the compiler will know that the value of home is of a specific type rather than being the union.

Exhaustiveness Checking

If we add a default case to the switch, we will see that home is of type never. The never type is a special type which can be assigned to any type - but no type can be assigned to never, except never itself.

Let’s demonstrate that by adding a default case:

1
2
3
4
5
6
7
8
9
10
11
function getFullAddress(home: Home) {
  switch (home.type) {
    case "apartment":
      return `${home.address}, level ${home.level}`;
    case "house":
      return home.address;
    default:
      const _exhaustiveCheck: never = home;
      return _exhaustiveCheck;
  }
}

Because there is no other type than "apartment" and "home", the type of home is narrowed down to never. And because home is narrowed to never, we are able to assign it to the variable _exhaustiveCheck: never.

As mentioned earlier, only never can be assigned to never, any other type cannot be assigned to never. This rule allows us to implement an exhaustive check as if we add a new type to the union, the compiler will complain that type x is not assignable to never.

To illustrate this, we add a new type to our union:

1
2
3
4
5
6
interface Boat {
  type: "boat";
  portAddress: string;
}

type Home = Apartment | House | Boat;

By adding Boat, the compiler will now highlight _exhaustiveCheck and complain with Type 'Boat' is not assignable to type 'never'.

Similarly the exauhstive check is also useful when the switch is in the form of if/else:

1
2
3
4
5
6
7
8
function getFullAddress(home: Home) {
  if (home.type === "apartment") return `${home.address}, level ${home.level}`;

  if (home.type === "house") return home.address;

  const _exhaustiveCheck: never = home;
  return _exhaustiveCheck;
}

and lastly we could also create a function which we can use instead of assigning a variable:

1
function exhaustiveCheck(_v: never) {}

which we can use:

1
2
3
4
5
6
7
8
9
10
function getFullAddress(home: Home) {
  switch (home.type) {
    case "apartment":
      return `${home.address}, level ${home.level}`;
    case "house":
      return home.address;
    default:
      exhaustiveCheck(home);
  }
}

If we miss a case, we will get the following compiler error on the argument passed to exhaustiveCheck: Argument of type 'Boat' is not assignable to parameter of type 'never'. And that concludes today’s post!

Conclusion

Today we looked at another type of narrowing with discriminated union. We saw how we could guide the compiler by using a switch case on a common attribute between the interfaces. We then moved on to look at how we could implement an exhaustive check of all the cases using the special never type. I hope you liked this post and I’ll see you on the next one!

Designed, built and maintained by Kimserey Lam.