beraliv

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.

Enum usage when we need to change value 'POST'
1enum HttpMethod {
2 Get = "GET",
3 Post = "POST",
4}
5
6const method: HttpMethod = HttpMethod.Post;
7// ^? 'POST'

This argument isn't very strong, because:

  1. Values are rarely changed
  2. 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.

You cannot use string literal values as string enums value
1const enum VolumeStatus {
2 AUDIBLE = "AUDIBLE",
3 MUTED = "MUTED",
4 FORCE_MUTED = "FORCE_MUTED",
5}
6
7class Volume {
8 public status: VolumeStatus = VolumeStatus.AUDIBLE;
9}
10
11const volume = new Volume();
12volume.status = VolumeStatus.AUDIBLE;
13
14// 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.

TypeScript allow to assign any number to variable of numeric enum type
1enum Output {
2 Error = 1,
3 Warning = 2,
4 Log = 3,
5}
6
7interface Options {
8 output?: Output;
9}
10
11const options: Options = {};
12options.output = Output.Error;
13options.output = Output.Warning;
14options.output = Output.Log;
15
16// oops, but still safe
17options.output = 3;
18
19// !!! 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.

TypeScript example
1function add(x: number, y: number): number {
2 return x + y;
3}
4
5add(1, 2); // Evaluates to 3

By removing types, it becomes valid JS code:

Same example but in JavaScript
1function add(x, y) {
2 return x + y;
3}
4
5add(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:

  1. They are incompatible with isolatedModules

  2. 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 🐞

  3. 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:

ApproachDeclarationStrict1Refactoring2Opaque-like3Bundle-size impact4
Numeric enumsenum A { X = 0, Y = 1 }3
String enumsenum A { X = 'X', Y = 'Y' }2
Heterogeneous enumsenum A { X = 0, Y = 'Y' }3
Numeric const enumsconst enum A { X = 0, Y = 1 }1
String const enumsconst enum A { X = 'X', Y = 'Y' }1
Heterogeneous const enumsconst enum A { X = 0, Y = 'Y' }1
Numeric ambient enumsdeclare enum A { X = 0, Y = 1 }0
String ambient enumsdeclare enum A { X = 'X', Y = 'Y' }0
Heterogeneous ambient enumsdeclare enum A { X = 0, Y = 'Y' }0
Numeric ambient const enumsdeclare const enum A { X = 0, Y = 1 }0
String ambient const enumsdeclare const enum A { X = 'X', Y = 'Y' }0
Heterogeneous ambient const enumsdeclare const enum A { X = 0, Y = 'Y' }0
Object + as constconst a = { X: 'X', Y: 'Y' } as const2
Union typestype A = 'X' | 'Y' 0
  1. All numeric enums (whether normal, heterogeneous, const or ambient) aren't strict as you can assign any number to the variable of its type.

  2. 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.

  3. We treat all string enums as opaque-like types. It means that only their values can be assigned to the variable of its type.

  4. 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:

Example with numeric enums
1enum Output {
2 Error = 1,
3 Warning = 2,
4 Log = 3,
5}
6
7interface Options {
8 output?: Output;
9}
10
11const options: Options = {};
12options.output = Output.Error;
13options.output = Output.Warning;
14options.output = Output.Log;
15
16// oops, but still safe
17options.output = 3;
18
19// !!! OOPS !!! unsafe 😅
20options.output = 4;
21options.output = 5;

After:

Same example with object, as const and Values
1const OUTPUT = {
2 Error: 1,
3 Warning: 2,
4 Log: 3,
5} as const;
6
7type Values<Type> = Type[keyof Type];
8
9type TOutput = Values<typeof OUTPUT>;
10
11interface Options2 {
12 output?: TOutput;
13}
14
15const options2: Options2 = {};
16options2.output = OUTPUT.Error;
17options2.output = OUTPUT.Warning;
18options2.output = OUTPUT.Log;
19
20// valid and safe
21options2.output = 3;
22
23// invalid
24options2.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:

Example with string const enum
1const enum OutputType {
2 LOG = "LOG",
3 WARNING = "WARNING",
4 ERROR = "ERROR",
5}
6
7type OutputEvent =
8 | { type: OutputType.LOG; data: Record<string, unknown> }
9 | { type: OutputType.WARNING; message: string }
10 | { type: OutputType.ERROR; error: Error };
11
12const output = (event: OutputEvent): void => {
13 console.log(event);
14};
15
16output({ type: OutputType.LOG, data: {} });
17output({ type: OutputType.WARNING, message: "Reload app" });
18output({ type: OutputType.ERROR, error: new Error("Unexpected error") });

After:

Same example with string literal types
1type OutputEvent2 =
2 | { type: "LOG"; data: Record<string, unknown> }
3 | { type: "WARNING"; message: string }
4 | { type: "ERROR"; error: Error };
5
6const output2 = (event: OutputEvent2): void => {
7 console.log(event);
8};
9
10output2({ 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:

Inferring type from OutputEvent2
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:

Union types
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

Enums
1// typescript
2enum A {
3 X = 0,
4 Y = "Y",
5}
6
7const A_X = A.X;
8const A_Y = A.Y;
9
10// javascript
11var A;
12(function (A) {
13 A[(A["X"] = 0)] = "X";
14 A["Y"] = "Y";
15})(A || (A = {}));
16
17const A_X = A.X;
18const A_Y = A.Y;
Const enums
1// typescript
2const enum A {
3 X = 0,
4 Y = "Y",
5}
6
7const A_X = A.X;
8const A_Y = A.Y;
9
10// javascript
11const A_X = 0; /* A.X */
12const A_Y = "Y"; /* A.Y */
Const enums with enabled preserveConstEnums
1// typescript
2const enum A {
3 X = 0,
4 Y = "Y",
5}
6
7const A_X = A.X;
8const A_Y = A.Y;
9
10// javascript
11var A;
12(function (A) {
13 A[(A["X"] = 0)] = "X";
14 A["Y"] = "Y";
15})(A || (A = {}));
16
17const A_X = 0; /* A.X */
18const A_Y = "Y"; /* A.Y */
Ambient enums
1// typescript
2declare enum A {
3 X = 0,
4 Y = "Y",
5}
6
7const A_X = A.X;
8const A_Y = A.Y;
9
10// javascript
11const A_X = A.X;
12const A_Y = A.Y;
Ambient const enums
1// typescript
2declare const enum A {
3 X = 0,
4 Y = "Y",
5}
6
7const A_X = A.X;
8const A_Y = A.Y;
9
10// javascript
11const A_X = 0; /* A.X */
12const A_Y = "Y"; /* A.Y */
Object + as const
1// typescript
2const a = {
3 X: 0,
4 Y: "Y",
5} as const;
6
7const A_X = a.X;
8const A_Y = a.Y;
9
10// javascript
11const a = {
12 X: 0,
13 Y: "Y",
14};
15
16const A_X = a.X;
17const A_Y = a.Y;
Union types
1// typescript
2type A = 0 | "Y";
3
4const A_X: A = 0;
5const A_Y: A = "Y";
6
7// javascript
8const A_X = 0;
9const A_Y = "Y";
  1. Difference between const enum and enum | Stackoverflow

  2. TS features to avoid | Execute Program

  3. Numeric enums | TypeScript Docs

  4. String enums | TypeScript Docs

  5. Heterogeneous enums | TypeScript Docs

  6. Const enums | TypeScript Docs

  7. Const enum pitfalls | TypeScript Docs

  8. Do you need ambient const enums or would a non-const enum work | TypeScript Issue comment

typescript
Alexey Berezin profile image

Written by Alexey Berezin who loves London 🏴󠁧󠁢󠁥󠁮󠁧󠁿, players ⏯ and TypeScript 🦺 Follow me on Twitter