Using TypeScript to write compile-time-safe enumeration type handlers

Well written types enforce compile-time guarantees in your code that needs no tests to guarantee functional correctness. At the very least…

Two people wearing safety helmets
Safety is important when things break. Photo by Anamul Rezwan from Pexels

Well written types enforce compile-time guarantees in your code that needs no tests to guarantee functional correctness. At the very least, they ensure necessities are not missed out. In this post, I hope to share a simple way to put TypeScript’s strengths to good use in handling enumerations at compile time, forcing consumers of an enumeration type to handle all cases intentionally — otherwise, we can make the compiler complain!

Bootstrapping a solution to tinker with

We all need to start somewhere

Let’s start with a simple source. If you’ve not set up a TypeScript project yet, let’s start with a simple node.js project. You can apply the technique in any of your current projects, but for people who want to hack around, I’m sharing how we can quickly setup a sample project. It also serves as a way for me to show actual terminal outputs etc so we have a shared starting point. If you already have a project to tinker with, skip to the next section.

To set up a TypeScript project, we can simply:

  1. Create a new directory
  2. Initialize it as a node project
  3. Add typescript and ts-node as dependencies
  4. Initialize it as a TypeScript project
  5. Setup sources
  6. Write a start script
  7. Get rocking!

I’m going to add a few commands here and hope you’re able to follow. If you need more help, let me know, and I’ll refine this section for newbies.

At the end of this you must have a project structure like this:

Folder structure after bootstrapping

At this point, we’re going to add a start script to our package.json which reads “ts-node src/index.ts”. Here’s how my package.json looks, for reference (my project name is different):

package.json for reference

After this step, we can always run our project by running the command npm run start or yarn start, depending on the tools we’re using. Right now our src/index.ts is blank, but we’ll fix that shortly.

Our first attempt at writing an enumeration handler

There’s always a first time for everything

In our src/index.ts we can now add some code that serves as a starting point:

Running the project as it exists gives us the following output:

Run results for our first version

Is the type definition even doing anything?

What kind of guarantees do we want?

When I define an enumeration type, I expect certain guarantees around the handling of it. For example, at minimum, I want all members to be handled. Otherwise, what’s the use of me using types at all, I can just use JavaScript and not worry about a thing!

Notice that even though we mentioned that color is of type Color, TypeScript didn’t warn us about not handling cases that are statically available like Color.Green and Color.Blue.

Usually, best practices and recommendations help us avoid problems like this. Here’s a recommendation/best practice that helps: all public APIs are strongly typed. If we look at the toColorString function, while its parameters are strongly typed, its return type is inferred, which is why TypeScript doesn’t complain about the code as it is. Let’s inspect the type of toColorString to see the inferred type:

Inferred type of toColorString

As we can see, the return type is “Red” | undefined which isn’t exactly what we’re looking for. Let’s try to change that.

Strongly typed public APIs

What does a small change do?

Notice that TypeScript complains as soon as we strongly type the API instead of leaving it to be inferred:

Strongly typing the public API raises issues immediately

With the code as it is now, we definitely need to handle all cases of the enumeration because the return type is a string and not handling a member case will result in an error due to the return type for that case being inferred as undefined.

Let’s fix the toColorString definition to satisfy our need:

Running the project now totally works:

Run results for the second version

Maintaining guarantees while handling the default case

Once we add a default handler, we lose all guarantees again — or do we?

It’s easy to see that the way we got rid of the error is not the only way. We could also have added a default case, like so:

And we get the following output:

Run results for the default version

Handling defaults seems like an acceptable solution except for when we want to add or change the members of an enumeration type. Consider the situation where we want to add the member Black to our Color enumeration. All handlers of the Color enumeration have a default behavior now that we may not want:

Run results for the black version

But I want the consumers to always handle the enumerations! Even if I handle the enumerations explicitly and then add a default case, the consumer isn’t forced to handle the new Color.Black enumeration member. Here’s the example where we handle all old members, and then introduce Color.Black:

Run results for the black with defaults version of the handler

Notice that even though Color.Black was a member we wanted to be handled, TypeScript doesn’t complain since we added a default handler for our switch statement. How do we make a public API, get code to compile, with defaults, and still get our guarantees? Is there a way? Thankfully, yes!

Let’s take advantage of the never type. First, let’s create a function that throws an Error, and therefore never returns. Since it’s regarding an unreachable enumeration member, let’s call it unreachable — it’s also supposed to be unreachable code for all intents and purposes. The member is supposed to never exist, so even the member is of type never.

In the default case of our handler, let’s call this function:

Notice that we’ve only covered the previous 3 enumerations and have left out Color.Black. This is now a compile-time error that TypeScript will complain about:

TypeScript complains that Color.Black is not handled

Once we handle the Color.Black case, the code compiles again, and runs:

Run results for the final version

Conclusion

All good things must come to an end

I hope you learnt something new today. I strongly believe compile-time type-safety can be used to help us code better. This is a simple technique to use the compiler to help us make fewer mistakes like not handling some edge cases (like not handling a new enumeration member). Try adding some new members and see how this helps! Integrate this technique into your existing codebases where you use enumerations often, and let me know how it works for you! As always, I’m open to feedback and look forward to your comments. Don’t forget to share it with anyone else who may find it useful!

Subscribe to The ArtfulDev Journal

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe