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:
- Create a new directory
- Initialize it as a node project
- Add typescript and ts-node as dependencies
- Initialize it as a TypeScript project
- Setup sources
- Write a start script
- 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:
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):
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:
Is the type definition even doing anything?
What kind of guarantees do we want?
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:
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:
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:
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:
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:
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:
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:
Once we handle the Color.Black case, the code compiles again, and runs:
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!