TypeScript Enums: The Complete How to Guide
TypeScript Enums are simple, yet powerful feature that can enhance code readability and simplify refactoring. However, your perception of just how useful they are, might depend on your background. If you’re coming from good ol’ JavaScript then this will be a life saver! If you’re coming from Java, they will not feel as powerful. And if you’re coming from a functional programming background then you might quickly notice that union types and immutable records can solve a lot of the same problems. (More on that at the end!)
In this article we’ll do a deep dive into what enums can do, when to use them, and how to manage conversion to and from strings and numbers.
How do TypeScript Enums Work Under the Hood?
Let’s start with a simple enum for our new recipe organizing app. It has the following categories:
enum RecipeType {
EVERYDAY,
SEASONAL,
EXPERIMENTAL,
EXPENSIVE,
MEAL_PREP
}
// usage
console.log(RecipeType.EVERYDAY) // => 0
Since we did not specify any actual values for our keys, TypeScript will give them numerical values by default, starting with 0
and auto-incrementing. So EVERYDAY
will be 0
, SEASONAL
will be 1
, and so on.
I think declaring an enum like this is wrong, or at least very situational. Order matters, and if we ever switch things up, the value of the keys will change. This can make life difficult if any of these values is being persisted somewhere.
Now, before we look at the solution, let’s acknowledge that this is clearly not just a type. It is some sort of object that actually stores a mapping of key/values while also generating a type on top of the keys.
Behind the scenes, this will compile into an actual JavaScript object that looks like this:
var RecipeType;
(function (RecipeType) {
RecipeType[RecipeType["EVERYDAY"] = 0] = "EVERYDAY";
RecipeType[RecipeType["SEASONAL"] = 1] = "SEASONAL";
RecipeType[RecipeType["EXPERIMENTAL"] = 2] = "EXPERIMENTAL";
RecipeType[RecipeType["EXPENSIVE"] = 3] = "EXPENSIVE";
RecipeType[RecipeType["MEAL_PREP"] = 4] = "MEAL_PREP";
})(RecipeType || (RecipeType = {}));
This means that the RecipeType
will get fully populated at runtime to contain both a mapping from value to key, and key to value:
{
0: "EVERYDAY",
1: "SEASONAL",
2: "EXPERIMENTAL",
3: "EXPENSIVE",
4: "MEAL_PREP",
EVERYDAY: 0,
SEASONAL: 1,
EXPERIMENTAL: 2,
EXPENSIVE: 3,
MEAL_PREP: 4
}
This feature only happens if you use the auto-incrementing feature. Let’s imagine we’re using our enum to map various keys to the same value:
enum RecipeType {
EVERYDAY = 'Regular',
SEASONAL = 'Special',
EXPERIMENTAL = 'Special',
EXPENSIVE = 'Regular',
MEAL_PREP = 'Regular',
}
This will compile down to a regular object:
{
"EVERYDAY": "Regular",
"SEASONAL": "Special",
"EXPERIMENTAL": "Special",
"EXPENSIVE": "Regular",
"MEAL_PREP": "Regular"
}
Even if the strings were unique, the effect would have been the same.
How to Get the TypeScript Enum from a String?
If we have a string, maybe from an API or something, that we need to map to a member of our enum we can just use the in
operator to check if it’s an actual member:
enum RecipeType {
EVERYDAY = 'Everyday',
SEASONAL = 'Seasonal',
}
const isRecipeType = (candidate: string): candidate is RecipeType => {
return candidate in RecipeType;
}
const candidate1 = 'EVERYDAY';
if (isRecipeType(candidate1)) { // true
console.log(RecipeType[candidate1]); // 'Everyday'
}
const candidate2 = 'random';
if (isRecipeType(candidate2)) { // false
console.log(RecipeType[candidate2]);
}
But what if want to do things the other way around? What if we have the value string, but not the key?
If we just want to check whether this value is actually part of the enum, we can make another assertions by playing with Object.values
. This way we can get all the enum values and search through them.
enum RecipeType {
EVERYDAY = 'Everyday',
SEASONAL = 'Seasonal',
}
function isRecipeTypeValue(value: string): boolean {
return Object.values(RecipeType).includes(value as RecipeType);
}
console.log(isRecipeTypeValue('Everyday')); // => true
console.log(isRecipeTypeValue('Random')); // => false
Unfortunately, we now have to cast our candidate string as a RecipeType, because otherwise we will get a type error that a string is not assignable as key.
Next up, you might wonder: how do we get the enum key if we have the value? We can go the other way around using Object.keys
which will let us enumerate all the keys of the enum.
enum RecipeType {
EVERYDAY = 'Everyday',
SEASONAL = 'Seasonal',
}
function getRecipeTypeKeyForValue(value: string): string | undefined {
return Object.keys(RecipeType).find(key => (RecipeType[key as keyof typeof RecipeType] === value));
}
console.log(getRecipeTypeKeyForValue('Everyday')); // => 'EVERYDAY'
console.log(getRecipeTypeKeyForValue('Random')); // => undefined
Life’s (Slightly) Easier with Numerical Values
Now, if you remember from earlier when the value is number a reverse mapping from value to key also ends up in the object. While I dislike programming to such an implementation detail, you do get the benefit of faster and easier checks. With our strings looking up if a key based on the value was a O(n)
search, but with numerical values (of a number type), the lookup is easy:
enum HttpStatusCode {
OK = 200,
BadRequest = 400,
NotFound = 404,
InternalServerError = 500,
}
const getCodeFromValue = (value: number): string => {
return HttpStatusCode[value];
}
console.log(getCodeFromValue(200)); // => 'OK'
console.log(getCodeFromValue(201)); // => undefiend
We also don’t have to do any type assertions because the type of the enums value is a number
and not a union type of strings, so technically any number is allowed.
However, the types are no longer that strong in this case. Notice how in the second case we got the value undefined
? The type system failed to give us a warning and catch this error for us during compile time.
Just Use Union Types and Records Instead?
An enum is essentially a hash map with some interesting add-ons and typing sugar. But as a concept, it is essentially a strongly typed hashmap. So why not use a Record
instead? If you’ve read my guide to TypeScript’s Record type, then you know just how powerful it can be.
Let’s refactor to it and see how we can represent this enum via a Record and a union type:
type EnumKey = 'OK' | 'BadRequest' | 'NotFound' | 'InternalServerError';
const HttpStatusCode: Record<EnumKey, number> = {
OK: 200,
BadRequest: 400,
NotFound: 404,
InternalServerError: 500,
}
const isValidKey = (key: string): key is EnumKey => {
return key in HttpStatusCode;
}
const getCodeFromValue = (value: number): string | undefined => {
const entry = Object.entries(HttpStatusCode).find(([_, val]) => val === value);
return entry?.[0];
}
console.log(isValidKey('OK')); // => true
console.log(isValidKey('Random')); // => false
console.log(getCodeFromValue(200)); // => 'OK'
console.log(getCodeFromValue(201)); // => undefined
The immediate downside is that the code is more verbose now. We had to add one extra line for the union type and we also spend a bit of time typing out the annotation for the Record.
But what we are left with are some pretty strong types! Notice how we don’t need to cast anything. And most importantly, if I has not added the explicit return annotation from getCodeFromValue
the compiler would have inferred it correctly!
I wouldn’t use this if I had an enum with a lot of values. For example, a project I worked on that a mapping of 200+ different hardware errors that were maintained by hand. (But, if I had a large list of code-generated enums, then I might go with the Record/Union approach.)
Comments ()