With or without enums in TypeScript
What
You're uncertain whether you need to use enums or not. Here are some points that make it easy to come to a conclusion.
Why use enums
No lookup objects for const enums
Values of const enums are inlined and lookup objects aren't emitted to JavaScript.
1// typescript2const enum Answer {3 No = "No",4 Yes = "Yes",5}67const yes = Answer.Yes;8const no = Answer.No;910// javascript11const yes = "Yes"; /* Answer.Yes */12const no = "No"; /* Answer.No */
🏝 Playground – https://tsplay.dev/m3Xg2W
See the difference in bundle size impact for enums and const enums
Refactoring
Given existing enum HttpMethod
, when you want to replace existing value "POST"
with e.g. "post"
, you change enum's value and you're done!
As you use references in your codebase, HttpMethod.Post
persists but only value is updated.
1enum HttpMethod {2 Get = "GET",3 Post = "POST",4}56const method: HttpMethod = HttpMethod.Post;7// ^? 'POST'
This argument isn't very strong, because:
- Values are rarely changed
- It's not only possible solution (union types, object +
as const
)
Opaque-like type
If you're not familiar with Opaque types, it's a way to declare types of the same structure, which are not assignable to each other.
The perfect example can be 2 currencies (e.g. USD and EUR). You cannot simply put dollars into euro account without taking into account currency exchange rate:
1declare const brand: unique symbol;23type Brand<K, T> = K & { readonly [brand]: T };45type USD = Brand<number, "USD">;6type EUR = Brand<number, "EUR">;78let euroAccount = 0 as EUR;9let dollarAccount = 50 as USD;1011// Error: Type '"USD"' is not assignable to type '"EUR"'.12euroAccount = dollarAccount;
String enums act like opaque types. It means that we can only assign values of this enum, but not string literals.
1const enum VolumeStatus {2 AUDIBLE = "AUDIBLE",3 MUTED = "MUTED",4 FORCE_MUTED = "FORCE_MUTED",5}67class Volume {8 public status: VolumeStatus = VolumeStatus.AUDIBLE;9}1011const volume = new Volume();12volume.status = VolumeStatus.AUDIBLE;1314// Error: Type '"AUDIBLE"' is not assignable to type 'VolumeStatus'.15volume.status = "AUDIBLE";
🏝 Playground – https://tsplay.dev/W4xY4W
Why not use enums
Numeric enums are NOT type-safe (TypeScript below 5.0)
Given numeric enum and any variable of its type, TypeScript 4.9.5 and below allowed you to assign any number to it.
1enum Output {2 Error = 1,3 Warning = 2,4 Log = 3,5}67interface Options {8 output?: Output;9}1011const options: Options = {};12options.output = Output.Error;13options.output = Output.Warning;14options.output = Output.Log;1516// oops, but still safe17options.output = 3;1819// !!! OOPS !!! unsafe 😅20options.output = 4;21options.output = 5;
Please check the playground to test it for TypeScript 4.9.5 - https://www.typescriptlang.org/play?preserveConstEnums=true&ts=4.9.5#code/KYOwrgtgBA8mAuAHBUDeAoKWoFEBOeA9nlALxQCMANJtgOoCGeIAliAOZlQBMN2UAGUKdyAZhoBfdOjbxgeAGYMAxsFiJ4LQiADOaWlkIJk8APwAuWMYQBudFPTLtO+FEIatuyzA-OuqCTt3TWcAOiMkFHI4SPhQ-CI8IN9dcOtXaPTQxmY2dmSQ1IiTLhiTUKF86QB6ard3HSooACMUFxYAGw6oHQYFYHRgzx002K5RO3RaqABCOdgYAAUAZVn5sF0+tUBeDcBQPcGUkeKoqAAWAuHRkvIAVhsgA 🏝️
Beginning with TypeScript 5.0, this issue has been resolved and addressed within the release, eliminating it as a concern going forward. For more details, please read TypeScript 5.0 Release | Enum Overhaul.
Playground for latest TypeScript - https://tsplay.dev/wXgg9W 🏝️
Enum is NOT just a type feature added
TypeScript is supposed to be JavaScript, but with static type features added.
If we remove all of the types from TypeScript code, what's left should be valid JavaScript code.
The formal word used in the TypeScript documentation is "type-level extension":
Most TypeScript features are type-level extensions to JavaScript, and they don't affect the code's runtime behaviour.
Given function add
in TypeScript:
1function add(x: number, y: number): number {2 return x + y;3}45add(1, 2); // Evaluates to 3
By removing types, it becomes valid JS code:
1function add(x, y) {2 return x + y;3}45add(1, 2); // Evaluates to 3
Unfortunately, enums break this rule (in comparison to classes which only add type information on top of existing JS code) for now.
You can simply try to execute this code in the browser and you will get a syntax error:
1// Uncaught SyntaxError: Unexpected reserved word2enum Answer { No = 0, Yes = 1 }
At the moment of writing this blog post, proposal for ECMAScript enums was on stage 0.
Const enum + preserveConstEnums option === enum + potential pitfalls
When you use const enums, their values are inlined and no lookup object is emitted to JavaScript.
However, when you enable preserveConstEnums option in tsconfig.json
, lookup object is emitted.
1// typescript2const enum Answer {3 No = 0,4 Yes = "Yes",5}67const yes = Answer.Yes;8const no = Answer.No;910// javascript11var Answer;12(function (Answer) {13 Answer[(Answer["No"] = 0)] = "No";14 Answer["Yes"] = "Yes";15})(Answer || (Answer = {}));1617const yes = "Yes"; /* Answer.Yes */18const no = 0; /* Answer.No */
In addition, when you publish const enums or consume them from declaration files, you may face ambient const enums pitfalls.
Choose your solution
Let's sum up what we just discussed in a table:
Approach | Declaration | No lookup objects1 | Type-safe2 | Refactoring3 | Optimal4 | Type-only5 | Opaque-like6 |
---|---|---|---|---|---|---|---|
Numeric enums | enum Answer { No = 0, Yes = 1 } | ❌ | ✅⚠️ | ✅ | ❌ | ❌ | ❌ |
String enums | enum Answer { No = 'No', Yes = 'Yes' } | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ |
Heterogeneous enums | enum Answer { No = 0, Yes = 'Yes' } | ❌ | ✅⚠️ | ✅ | ❌ | ❌ | ❌ |
Numeric const enums | const enum Answer { No = 0, Yes = 1 } | ✅ | ✅⚠️ | ✅ | ✅ | ❌ | ❌ |
String const enums | const enum Answer { No = 'No', Yes = 'Yes' } | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Heterogeneous const enums | const enum Answer { No = 0, Yes = 'Yes' } | ✅ | ✅⚠️ | ✅ | ✅ | ❌ | ❌ |
Object + as const | const ANSWER = { No: 0, Yes: "Yes" } as const | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
Union types | type Answer = 0 | 'Yes' | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ |
-
Union types are type-only feature so no JS code is emitted. Const enums inline their values and don't emit lookup objects. Other solutions, i.e. object + as const and normal enums, emit lookup objects.
-
As mentioned above, all numeric enums (whether normal, heterogeneous or const) are type-safe starting with TypeScript 5.0 onwards. But if TypeScript in your project is 4.9.5 or below, any numeric enums won't be type-safe as you can assign any number to the variable of its type.
-
Only union type lacks refactoring. It means that if you need to update value in a codebase, you will require to run type check over your codebase and fix all type errors. Enums and objects encapsulate it by saving lookup object.
-
To be able to compare approaches between each other, please have a look at Bundle-size impact.
-
Only union types are type-only feature. Other solutions emit lookup objects or aren't just a type feature added.
-
We treat all string enums as opaque-like types. It means that only their values can be assigned to the variable of its type.
How to get rid of enums
If you decided to get rid of enums, here are my suggestions.
Numeric enum => object + as const + Values
We can use as const
and expose JS objects the same way we do it with numeric enums but in a safe way.
It's also included in Enums - Objects vs. Enums | TypeScript Docs
Before:
1enum Output {2 Error = 1,3 Warning = 2,4 Log = 3,5}67interface Options {8 output?: Output;9}1011const options: Options = {};12options.output = Output.Error;13options.output = Output.Warning;14options.output = Output.Log;1516// oops, but still safe17options.output = 3;1819// safe starting with TypeScript 5.0 😅20options.output = 4;21options.output = 5;
After:
1const OUTPUT = {2 Error: 1,3 Warning: 2,4 Log: 3,5} as const;67type Values<Type> = Type[keyof Type];89type TOutput = Values<typeof OUTPUT>;1011interface Options2 {12 output?: TOutput;13}1415const options2: Options2 = {};16options2.output = OUTPUT.Error;17options2.output = OUTPUT.Warning;18options2.output = OUTPUT.Log;1920// valid and safe21options2.output = 3;2223// invalid24options2.output = 4;25options2.output = 5;
🏝 Together in Playground – https://tsplay.dev/wOXX6N
String const enum => union type + inlined string literals
Values within string const enums are usually self-explanatory, so we can use union types without losing readability.
Bundle size will be the same as same string literals are inlined when you use const enum.
Before:
1const enum OutputType {2 LOG = "LOG",3 WARNING = "WARNING",4 ERROR = "ERROR",5}67type OutputEvent =8 | { type: OutputType.LOG; data: Record<string, unknown> }9 | { type: OutputType.WARNING; message: string }10 | { type: OutputType.ERROR; error: Error };1112const output = (event: OutputEvent): void => {13 console.log(event);14};1516output({ type: OutputType.LOG, data: {} });17output({ type: OutputType.WARNING, message: "Reload app" });18output({ type: OutputType.ERROR, error: new Error("Unexpected error") });
After:
1type OutputEvent2 =2 | { type: "LOG"; data: Record<string, unknown> }3 | { type: "WARNING"; message: string }4 | { type: "ERROR"; error: Error };56const output2 = (event: OutputEvent2): void => {7 console.log(event);8};910output2({ type: "LOG", data: {} });11output2({ type: "WARNING", message: "Reload app" });12output2({ type: "ERROR", error: new Error("Unexpected error") });
If you need to keep values (i.e. "LOG" | "WARNING" | "ERROR"
) in a separate type, like OutputType
previously in enum, you still can do it:
1type TOutput = OutputEvent2["type"];2// ^? "LOG" | "WARNING" | "ERROR"
🏝 Together in Playground – https://tsplay.dev/mM1klm
Numeric const enums => it depends
Values within numeric const enums are usually unreadable (e.g. 0
, 1
).
When a meaning doesn't make much sense, you can still use union type:
1type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
Otherwise, follow the approach with Numeric enum => object + as const + Values.
It will definitely increase your bundle size. But again, it will keep you code type-safe by eliminating assignment of any number.
To compare the bundle size, please have a look at Bundle size impact
What about ambient enums
Apart from enums and const enums, there are ambient enums.
It's a way to describe the shape of existing enum types, e.g.:
1declare enum Colour {2 Red = "red",3 Green = "green",4 Blue = "blue",5}
Usually you can find them in declaration files, e.g. @prisma/client
1declare enum IsolationLevel {2 ReadUncommitted = "ReadUncommitted",3 ReadCommitted = "ReadCommitted",4 RepeatableRead = "RepeatableRead",5 Snapshot = "Snapshot",6 Serializable = "Serializable",7}
It's still very unlikely that you use ambient enums directly in your codebase. I would recommend to avoid using them.
Ambient const enum pitfalls
If you DO use them, you probably already know that inlining enum values come with subtle implication, here are some of them:
-
They are incompatible with isolatedModules option in
tsconfig.json
-
If you export const enums and provide them as an API to other libraries, it can lead to surprising bugs, e.g. Const enums in the TS Compiler API can make depending on typescript difficult 🐞
-
Unresolvable imports for const enums used as values cause errors at runtime with importsNotUsedAsValues option in
tsconfig.json
set to"preserve"
TypeScript advises to:
A. Do not use const enums at all. You can easily ban const enums with the help of a linter. Obviously this avoids any issues with const enums, but prevents your project from inlining its own enums. Unlike inlining enums from other projects, inlining a project’s own enums is not problematic and has performance implications.
B. Do not publish ambient const enums, by deconstifying them with the help of
preserveConstEnums
. This is the approach taken internally by the TypeScript project itself.preserveConstEnums
emits the same JavaScript for const enums as plain enums. You can then safely strip the const modifier from .d.ts files in a build step.
Read more about const enum pitfalls.
Bundle size impact
Let's first have a look at examples (I left only examples with heterogeneous values):
1// typescript2enum Answer {3 No = 0,4 Yes = "Yes",5}67const yes = Answer.Yes;8const no = Answer.No;910// javascript11var Answer;12(function (Answer) {13 Answer[(Answer["No"] = 0)] = "No";14 Answer["Yes"] = "Yes";15})(Answer || (Answer = {}));1617const yes = Answer.Yes;18const no = Answer.No;
1// typescript2const enum Answer {3 No = 0,4 Yes = "Yes",5}67const yes = Answer.Yes;8const no = Answer.No;910// javascript11const yes = "Yes"; /* Answer.Yes */12const no = 0; /* Answer.No */
1// typescript2const enum Answer {3 No = 0,4 Yes = "Yes",5}67const yes = Answer.Yes;8const no = Answer.No;910// javascript11var Answer;12(function (Answer) {13 Answer[(Answer["No"] = 0)] = "No";14 Answer["Yes"] = "Yes";15})(Answer || (Answer = {}));1617const yes = "Yes"; /* Answer.Yes */18const no = 0; /* Answer.No */
1// typescript2declare enum Answer {3 No = 0,4 Yes = "Yes",5}67const yes = Answer.Yes;8const no = Answer.No;910// javascript11const yes = Answer.Yes;12const no = Answer.No;
1// typescript2declare const enum Answer {3 No = 0,4 Yes = "Yes",5}67const yes = Answer.Yes;8const no = Answer.No;910// javascript11const yes = "Yes"; /* Answer.Yes */12const no = 0; /* Answer.No */
1// typescript2const ANSWER = {3 No: 0,4 Yes: "Yes",5} as const;67const yes = ANSWER.Yes;8const no = ANSWER.No;910// javascript11const ANSWER = {12 No: 0,13 Yes: "Yes",14};1516const yes = ANSWER.Yes;17const no = ANSWER.No;
1// typescript2type Answer = 0 | "Yes";34const yes: Answer = "Yes";5const no: Answer = 0;67// javascript8const yes = "Yes";9const no = 0;
Given 3 different types of values (numeric, string and heterogeneous), let's compare the bundle size (in bytes) of different solutions:
Approach | Enum | Const enum | Const enum + preserveConstEnums | Object + as const | Union type |
---|---|---|---|---|---|
Numeric values | 126 | 44 | 112 | 80 | 44 |
Heterogeneous values | 124 | 48 | 117 | 83 | 48 |
String values | 116 | 49 | 108 | 83 | 49 |
When you need to keep a lookup object (enum, const enum + preserveConstEnums
and object + as const
), the optimal solution is always an object + as const
.
When you don't need a lookup object (const enum and union type), both const enum and union type are optimal.
If you're interested in comparison itself, please go to Github repo with-or-without-enums-bundle-size-impact 🔗
Shout out
This article couldn't be that good without the help of:
Thank you mates, your feedback was really helpful! 👏
Links 🔗
-
How do the different enum variants work in TypeScript? | Stack Overflow
-
Do you need ambient const enums or would a non-const enum work | TypeScript Issue comment