Exhaustively handle enum values in switch/case at compile time

Oluwafemi Shobande
Typescript Tidbits
Published in
3 min readFeb 26, 2024

Note: This post assumes basic understanding of Type narrowing via equality and the never type.

Many Typescript users have a love/hate relationship with enums. Admittedly, their current implementation present some interesting challenges which we will not explore in this piece (maybe we’ll do this some other time). Furthermore, some will die on the hill of not using enums in combination with switch/case. Again, we will not expound on this at this time, I’m sorry.

The purpose of this post is to showcase how to ensure compile time exhaustive checks for enums when they are used in combination with switch/case statements.

Problem

Suppose I am an international chef that’s mastered the art of cooking both Nigerian and Ghanaian Jollof (fun fact; Nigerians and Ghanaians have a long standing war of who cooks better Jollof), the process of cooking a country’s version of this meal may be represented by the code snippet below:

enum SupportedCountry {
NIGERIA = "nigeria",
GHANA = "ghana"
}

function cookJollof(country: SupportedCountry) {
switch (country) {
case SupportedCountry.NIGERIA:
cookNigerianJollof();
break;
case SupportedCountry.GHANA:
cookGhanaianJollof();
break;
default:
throw new Error("unsupported country");
}
}

function cookNigerianJollof() {
// ...
}

function cookGhanaianJollof() {
// ...
}

Do you see the issue with the code above?

There are 2 sources of truth for supported countries. First is the enumeration, second is the handling of each value in the enum within the switch/case. For example, we may add Senegal as a new value to the SupportedCountry enum, but this country still wouldn’t be supported until it is handled by the cookJollof function, otherwise, an error is thrown.

To make matters worse, in a real codebase, the definitions of SupportedCountry and cookJollof will likely exist in separate files. Furthermore, there may be other functions that similarly try to perform some logic based on the country provided.

Extending this enum with new values can soon become a nightmare because the work of ensuring that this new value is handled by every switch/case statement using the enum must be done by the developer. As you know, humans are prone to mistakes, and it isn’t uncommon to add extend an enum and forget to handle the new value. The unwanted consequence of this is throwing runtime errors.

enum SupportedCountry {
NIGERIA = "nigeria",
GHANA = "ghana",
SENEGAL = "senegal" // new value
}

function cookJollof(country: SupportedCountry) {
switch (country) {
case SupportedCountry.NIGERIA:
cookNigerianJollof();
break;
case SupportedCountry.GHANA:
cookGhanaianJollof();
break;
default:
throw new Error("unsupported country"); // senegal ends up here
}
}

What if there was a way to make compilation fail if new enum values aren’t handle by switch/case statements? Well, there is!

Solution

We can employ Typescript’s ability to narrow types. Within the first case, Typescript narrows the type of country to SupportedCountry.NIGERIA (not SupportedCountry), likewise, within the second case, country’s type is SupportedCountry.GHANA.

This leads us to ask what type is associated with country within the default branch. You guess right; type never. This type is used to represent a state which should not exist. Riding on this fact, ensuring that all values of an enum has been handled by some case is a matter of asserting that in the default branch, the type of country is never. This assertion can simply be done by passing country into a function shouldBeUnreachable that accepts a parameter of type never. If country is never, compilation will pass, otherwise, compilation will fail.

In the code snippet below, all enum values are handled, so it compiles.

enum SupportedCountry {
NIGERIA = "nigeria",
GHANA = "ghana"
}

function shouldBeUnreachable(value: never) {}

function cookJollof(country: SupportedCountry) {
switch (country) {
case SupportedCountry.NIGERIA:
cookNigerianJollof();
break;
case SupportedCountry.GHANA:
cookGhanaianJollof();
break;
default:
shouldBeUnreachable(country); //✅︎
}
}

However, the moment a new value is added to the enum, compilation fails because country is no longer of type never.

enum SupportedCountry {
NIGERIA = "nigeria",
GHANA = "ghana",
SENEGAL = "senegal" // new value
}

function shouldBeUnreachable(value: never) {}

function cookJollof(country: SupportedCountry) {
switch (country) {
case SupportedCountry.NIGERIA:
cookNigerianJollof();
break;
case SupportedCountry.GHANA:
cookGhanaianJollof();
break;
default:
shouldBeUnreachable(country); //❌
}
}

If you like this content, kindly subscribe to this publication as I will be sharing many more neat tricks. Also here’s my Twitter, if you’d like to reach out.

--

--