Are Typecript Enums Really Harmful?
Michael Kim11 min read
š¤ŗ Are TypeScript Enums Really Harmful?
One of the current trends in the TypeScript space seems to be criticising TypeScript native enums and claiming they are unsafe to use.
I first came across the āenum dramaā through videos from big creators in the space such as Matt Pocock and Theo, who adamantly oppose their usage with theatrical titles such as āTypeScriptās worst featureā, āEnums considered Harmfulā and my personal favourite āEnums hurt meā.
Initially, I disregarded these opinions as over-analyses of inconsequential TypeScript features, but once code review comments began suggest alternatives to TypeScript native enums, I became genuinely curious - is my usage of enums actually dangerous and harming my codebase quality?
ā What are Enums anyway?
For those who arenāt yet familiar with TypeScript enums, hereās a quick re-cap. Enums are short for enumerations, which represent a set of pre-defined, constant values.
Enums are an idiom used across most popular programming languages and typically exist as a data construct that captures some exhaustive list of fixed values - an enum for the directions on a compass might look like this in TypeScript:
enum CompassDirections {
NORTH
EAST
SOUTH
WEST
}
There are a humourously wide variety of TypeScript enums, such as numeric enums, string enums, heterogenous enums, const enums, union enums, ambient enums and more!
Fundamentally though, they all represent the same programming idiom - a list of fixed, known values.
š Why shouldnāt you use Enums?
At first glance, enums are great - they allow you to store a set of read-only values in a concise, typesafe manner. Yet in some cases they arenāt as great as they might seem. Letās analyse this through an example.
Hereās a TypeScript enum to list the fruit in my fridge:
enum FruitEnumTypeScript {
APPLE,
BANANA,
MANGO
}
Now letās say I want to view all the fruit in my fridge - hereās what I see:
const fridgeFruit = Object.keys(FruitEnumTypeScript);
console.log(fridgeFruit);
// Produces -> [(0, 1, 2, Apple, Banana, Mango)];
Uh-oh! Why are we suddenly seeing these magical numbers at the start of our array?
The āhiddenā truth is that TypeScript enums are transpiled to JavaScript objects in an unintuitive way. Bizzarely, at runtime, the enum will eventually be translated down to look like this:
const FruitEnumTypeScript = {
APPLE: 0,
0: "APPLE",
BANANA: 1,
1: "BANANA",
MANGO: 2,
2: "MANGO",
};
So doing normal JS object manipulation can be annoying - for example, mapping over the keys of an enum, which is a fairly common operation for any list of values. Intuitively, we expect the enum to be a simple dictionary of enum keys and values, but instead we get a strange and unintuitive shape.
In my opinion, this is the most credible argument against TypeScript native enums - basic data manipulation is a common operation on lists, and the transpilation issue above is an easy source of bugs and confusion.
š¦ø POJO Enums to the rescue!
How can we avoid the weird object transpiling issue?
One way is by using POJOs (or plain old JavaScript objects), which, as the name suggests, uses a simple JavaScript object in place of a TypeScript enum which will look like this:
const FruitEnumPojo = {
APPLE: "apple",
BANANA: "banana",
MANGO: "mango",
} as const;
As seen, there are no tricks employed here, this is a plain and simple JavaScript object, with an as const
TypeScript assertion that enforces the object contents as read-only. Because there is no TypeScript transpiling whatsoever, there cannot be any unexpected translations and āwhat you see is what you getā.
š¤ POJO Enums arenāt perfect
So āPOJOā enums seem to get around the object transpiling issue - why not just use these JS āenumsā all the time?
There is one major drawback - they allow for the usage of the enumās primitive values directly in code (such as "apple"
above) instead of throwing type errors and demanding something like FruitEnumPojo.APPLE
.
// Helper type to get a type for possible keys of enum
type ObjectValues<T> = T[keyof T]
const test = (arg1: ObjectValues<typeof FruitEnumPojo>) => {
...
}
// No type errors, and does not demand FruitEnumPojo.APPLE
test('apple')
In my opinion, such interchangeable usage of JS literals such as "apple"
with fixed enum members like FruitEnum.APPLE
encourages the use of āmagicā values without a reference, and thus
- Makes refactoring harder as there is no single source of truth, and each instance of
"apple"
must be modified if the enum memberās value ever changes - Reduces the clarity of your code, because seeing
test("apple")
may raise questions such as: is āappleā definitely a valid argument, and is it typed properly?
š§µ My Personal Choice - String Enums
So, What is the happy middle? My personal choice to represent fixed values are TypeScript string enums that look something like this:
enum FruitStringEnum {
APPLE = "apple",
BANANA = "banana",
MANGO = "mango",
}
The usage of string enums in my opinions avoids most of the pitfalls commonly attributed to TypeScript ādefaultā enums. The transpiled JS is intuitive and as expected, matching the output of the JS enum with const assertion like so:
Object.entries(FruitStringEnum)
// Produces:
// (["APPLE", "apple"], ["BANANA", "banana"], ["MANGO", "mango"])
Additionally, we can be stricter about accessing enum members explicitly rather than with magic values like "apple"
, and throw type errors otherwise.
// Helper type to get a type for possible keys of enum
type ObjectValues<T> = T[keyof T]
const test = (arg1: ObjectValues<typeof FruitStringEnum>) => {
...
}
// Type error thrown! Demands FruitStringEnum.APPLE
test("apple")
// No Type Error
test(FruitStringEnum.APPLE)
š§ But what about Union Types?
OK, so string enums are great - but what about union types? Union types are another way to represent a list of fixed values through TypeScript, but their pros and cons when compared to TypeScript enums are often confused. First and foremost, hereās what a union type looks like:
type Fruits = "APPLE" | "BANANA" | "MANGO"
Amazing! Union types appear to offer a more concise, readable list of values than TypeScript enums do! The key difference between union types and enums (apart from their syntax), is that union types are purely compile time concepts, whereas enums emit JavaScript objects that will exist at runtime.
This means object manipulation, logging to console and other runtime actions will not be available for union types.
In some cases, union types can actually offer benefits that enums cannot. For example, when there is high certainty that the values to be represented in the enum do not require any advanced object manipulation, it may be preferable to use union types instead as a quick and lightweight way to represent a list of fixed values.
What is important is understanding that union types pose a tradeoff between more concise syntax, versus increased difficulty in refactoring, and lack of type safety for specific enum members.
Why is type safety compromised in some cases? Because it is not possible to access specific enum members and their values in the typical ENUM.MEMBER
fashion, and so it is also not possible to specify type checking for a specific enum member without defining a new type, or typing the argument as a string literal.
For the same reasons, union types require the use of string literals in lieu of explicit ENUM.MEMBER
accesses.
type Fruits = "APPLE" | "BANANA" | "MANGO"
// Cannot specify that function only takes Fruits.APPLE
const printApple = (fruit: Fruits) => {
console.log(fruit)
}
// Cannot pass Fruits.APPLE instead of "APPLE"
printApple("APPLE")
In the above snippet, the union type requires the literal "APPLE"
to be passed, so the code will be harder to refactor just as in the case of the POJO enum.
In the case that printApple(...)
is called multiple times in the codebase and we want to update the naming to be more specific or fix a typo, we will have to do this for each instance of the printApple(<STRING_TO_CHANGE>)
call.
š§¶ Ok, so should we always use String Enums?
The short answer is probably, because TypeScript string enums should satisfy the majority of conventional use cases of idiomatic enums. The only cases where string enums may be counter-productive could be when:
1. The developer team features inexperienced devs / poor communication
In the case where the team includes inexperienced developers, using string enums may elevate the risk of the accidental usage of harmful enum variants such as numeric and non-string TypeScript enums.
As a once new developer, I admit that I had no idea about the difference in enum variants, the object transpilation issue, and assumed that since the TypeScript keyword enum
was used in code, it was safe in all regards.
As per Nicholas C. Zakasā āBunny Theory of Codeā, once non-string enums are injected into the code-base, it is more likely they will be copied across files, leading to inconsistencies and the propagation of error-prone code.
Of course, arguably the best solution to this problem lies in training the team on best practices and properly enforcing best practices (e.g. through ESLint rules), but in a project with a tight deadline and limited resources for training or refactoring, it may be better to opt for POJO enums for which bad practices are āharderā to do, or to sacrifice.
2. Low likelihood of object manipulation
As discussed, union types may pose an advantage over string enums if the following is true:
- There is certainty that the enum values do not require any object manipulation
- There is no need for type safety over specific enum members
- The use of string literals instead of
ENUM.MEMBER
is not important to you
In such a case, union types can offer a more concise and readable implementation of the enum idiom.
type Fruits = "APPLE" | "BANANA" | "MANGO"
3. You care a lot about structural vs nominal type systems
TypeScript technically claims to be a āstructuralā type system and not a ānominalā type system. Nominal type systems consider each defined type to be unique, and thus even if types share the same data or value, they cannot be referenced equivalently.
As a structural type system, TypeScript only cares about the shape and value of things - not names; āIf the type is shaped like a duck, itās a duckā.
When using TypeScript enums, explicit ENUM.MEMBER
accesses cannot be used interchangeably with string literals, and so for some developers this may challenge their pre-suppositions about TypeScript as a type system.
This isnāt the only instance where our assumptions about TypeScript have been challenged. For instance, think back to the object transpilation issue - TypeScript enums are technically breaking our expectations by producing JavaScript objects that live at runtime, which may cause confusion as we often think of TypeScript as only existing at compile-time.
This is especially unintuitive given that this breaks one of TypeScriptās non-goals, explicitly stating that it should not provide additional runtime functionality.
In the case that this is important to you as a developer, avoiding the use of enums in order to use TypeScript more purely as a compile-time, structural type system may provide a better developer experience.
šŖ Closing Thoughts
Overall, I have to disagree with the vehement aggression towards TypeScript enums. I donāt think they are the āworst feature in TypeScriptā or that āenums hurt meā!
In my opinion, string enums satisfy 90% of the criticism of enums overall, the strongest of which being the strange object transpilation issue.
Admittedly, it is just as safe and only marginally more verbose to use POJO enums which are unlikely to change in implementation as they are simply JS objects, and which provide the same benefits without having to concern developers with all the semantics of different enum variants.
Yet personally the strange syntax of POJOS (const ... as const
) is less readable and may be confusing for newer developers.
When it comes down to it, I would recommend the use of string enums by default as they are intuitive, refactorable and discourage the use of āmagicā string values.
Of course, despite having written a full article on enums, it must be stated that the enum variant chosen alone is not the most important architectural decision that will make or break your product.
However - it is interesting to see and understand the confusing drama behind enums. Surprisingly, these small best practices can add up to a big difference in codebase quality. At the very least, it is something to learn once and not have to think about again.
If you disagree or have any further comments or questions, please donāt hesitate to contact me directly - I would be very happy to change my mind and fill any personal knowledge gaps š!