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 not (pros) ✅
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
Only string enums act like opaque types. It means that we cannot assign string literal values.
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 (cons) ❌
Numeric enums are NOT safe
Given numeric enum and any variable of its type, TypeScript allow 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;
🏝 Playground – https://tsplay.dev/mx3rBN
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.
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.
Const enum + preserveConstEnums option === enum + potential surprising bugs
Some projects use const enums as normal enums by enabling preserveConstEnums.
See bundle-size impact for const enums with enabled preserveConstEnums
Ambient const enum pitfalls
Ambient enums are rarely used in a codebase. 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
-
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: "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.
Choose your solution
Let's sum up what we just discussed in a table:
Approach | Declaration | Strict1 | Refactoring2 | Opaque-like3 | Bundle-size impact4 |
---|---|---|---|---|---|
Numeric enums | enum A { X = 0, Y = 1 } | ❌ | ✅ | ❌ | 3 |
String enums | enum A { X = 'X', Y = 'Y' } | ✅ | ✅ | ✅ | 2 |
Heterogeneous enums | enum A { X = 0, Y = 'Y' } | ❌ | ✅ | ❌ | 3 |
Numeric const enums | const enum A { X = 0, Y = 1 } | ❌ | ✅ | ❌ | 1 |
String const enums | const enum A { X = 'X', Y = 'Y' } | ✅ | ✅ | ✅ | 1 |
Heterogeneous const enums | const enum A { X = 0, Y = 'Y' } | ❌ | ✅ | ❌ | 1 |
Numeric ambient enums | declare enum A { X = 0, Y = 1 } | ❌ | ✅ | ❌ | 0 |
String ambient enums | declare enum A { X = 'X', Y = 'Y' } | ✅ | ✅ | ✅ | 0 |
Heterogeneous ambient enums | declare enum A { X = 0, Y = 'Y' } | ❌ | ✅ | ❌ | 0 |
Numeric ambient const enums | declare const enum A { X = 0, Y = 1 } | ❌ | ✅ | ❌ | 0 |
String ambient const enums | declare const enum A { X = 'X', Y = 'Y' } | ✅ | ✅ | ✅ | 0 |
Heterogeneous ambient const enums | declare const enum A { X = 0, Y = 'Y' } | ❌ | ✅ | ❌ | 0 |
Object + as const | const a = { X: 'X', Y: 'Y' } as const | ✅ | ✅ | ❌ | 2 |
Union types | type A = 'X' | 'Y' | ✅ | ❌ | ❌ | 0 |
-
All numeric enums (whether normal, heterogeneous, const or ambient) aren't strict as you can assign any number to the variable of its type.
-
Because union type is type-only feature, it 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 the mapping in its structure.
-
We treat all string enums as opaque-like types. It means that only their values can be assigned to the variable of its type.
-
Only union is a type-only feature, meaning there's no JS code added. Const enums leave only values. String enums leave the whole object structure. Numeric enums (normal and heterogeneous) leave mirror object structure. See the comparison below.
How (Proposal) ❓
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 TypeScript Docs | Enums - Objects vs. Enums
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// !!! OOPS !!! unsafe 😅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/Nr4r3W
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 => open to suggestions
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 safe by eliminating assignment of any number.
Bundle size impact
1// typescript2enum A {3 X = 0,4 Y = "Y",5}67const A_X = A.X;8const A_Y = A.Y;910// javascript11var A;12(function (A) {13 A[(A["X"] = 0)] = "X";14 A["Y"] = "Y";15})(A || (A = {}));1617const A_X = A.X;18const A_Y = A.Y;
1// typescript2const enum A {3 X = 0,4 Y = "Y",5}67const A_X = A.X;8const A_Y = A.Y;910// javascript11const A_X = 0; /* A.X */12const A_Y = "Y"; /* A.Y */
1// typescript2const enum A {3 X = 0,4 Y = "Y",5}67const A_X = A.X;8const A_Y = A.Y;910// javascript11var A;12(function (A) {13 A[(A["X"] = 0)] = "X";14 A["Y"] = "Y";15})(A || (A = {}));1617const A_X = 0; /* A.X */18const A_Y = "Y"; /* A.Y */
1// typescript2declare enum A {3 X = 0,4 Y = "Y",5}67const A_X = A.X;8const A_Y = A.Y;910// javascript11const A_X = A.X;12const A_Y = A.Y;
1// typescript2declare const enum A {3 X = 0,4 Y = "Y",5}67const A_X = A.X;8const A_Y = A.Y;910// javascript11const A_X = 0; /* A.X */12const A_Y = "Y"; /* A.Y */
1// typescript2const a = {3 X: 0,4 Y: "Y",5} as const;67const A_X = a.X;8const A_Y = a.Y;910// javascript11const a = {12 X: 0,13 Y: "Y",14};1516const A_X = a.X;17const A_Y = a.Y;
1// typescript2type A = 0 | "Y";34const A_X: A = 0;5const A_Y: A = "Y";67// javascript8const A_X = 0;9const A_Y = "Y";