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 use enums

No lookup objects for const enums

Values of const enums are inlined and lookup objects aren't emitted to JavaScript.

Const enums
1// typescript
2const enum Answer {
3 No = "No",
4 Yes = "Yes",
5}
6
7const yes = Answer.Yes;
8const no = Answer.No;
9
10// javascript
11const 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.

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

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:

Opaque type example
1declare const brand: unique symbol;
2
3type Brand<K, T> = K & { readonly [brand]: T };
4
5type USD = Brand<number, "USD">;
6type EUR = Brand<number, "EUR">;
7
8let euroAccount = 0 as EUR;
9let dollarAccount = 50 as USD;
10
11// 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.

You cannot use string literals as string enum 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 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.

TypeScript allow to assign any number to variable of numeric enum type for TypeScript 4.9.5 and below
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;

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:

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.

You can simply try to execute this code in the browser and you will get a syntax error:

Enum is reserved keyword but cannot be used now
1// Uncaught SyntaxError: Unexpected reserved word
2enum 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.

Const enums with enabled preserveConstEnums
1// typescript
2const enum Answer {
3 No = 0,
4 Yes = "Yes",
5}
6
7const yes = Answer.Yes;
8const no = Answer.No;
9
10// javascript
11var Answer;
12(function (Answer) {
13 Answer[(Answer["No"] = 0)] = "No";
14 Answer["Yes"] = "Yes";
15})(Answer || (Answer = {}));
16
17const 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:

ApproachDeclarationNo lookup objects1Type-safe2Refactoring3Optimal4Type-only5Opaque-like6
Numeric enumsenum Answer { No = 0, Yes = 1 }✅⚠️
String enumsenum Answer { No = 'No', Yes = 'Yes' }
Heterogeneous enumsenum Answer { No = 0, Yes = 'Yes' }✅⚠️
Numeric const enumsconst enum Answer { No = 0, Yes = 1 }✅⚠️
String const enumsconst enum Answer { No = 'No', Yes = 'Yes' }
Heterogeneous const enumsconst enum Answer { No = 0, Yes = 'Yes' }✅⚠️
Object + as constconst ANSWER = { No: 0, Yes: "Yes" } as const
Union typestype Answer = 0 | 'Yes'
  1. 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.

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

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

  4. To be able to compare approaches between each other, please have a look at Bundle-size impact.

  5. Only union types are type-only feature. Other solutions emit lookup objects or aren't just a type feature added.

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

Example with numeric enums for TypeScript 4.9.5 or below
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// safe starting with TypeScript 5.0 😅
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/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:

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 => 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:

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

Declaration of ambient enum
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

@prisma/client/runtime/index.d.ts
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:

  1. They are incompatible with isolatedModules option in tsconfig.json

  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 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):

Enums
1// typescript
2enum Answer {
3 No = 0,
4 Yes = "Yes",
5}
6
7const yes = Answer.Yes;
8const no = Answer.No;
9
10// javascript
11var Answer;
12(function (Answer) {
13 Answer[(Answer["No"] = 0)] = "No";
14 Answer["Yes"] = "Yes";
15})(Answer || (Answer = {}));
16
17const yes = Answer.Yes;
18const no = Answer.No;
Const enums
1// typescript
2const enum Answer {
3 No = 0,
4 Yes = "Yes",
5}
6
7const yes = Answer.Yes;
8const no = Answer.No;
9
10// javascript
11const yes = "Yes"; /* Answer.Yes */
12const no = 0; /* Answer.No */
Const enums with enabled preserveConstEnums
1// typescript
2const enum Answer {
3 No = 0,
4 Yes = "Yes",
5}
6
7const yes = Answer.Yes;
8const no = Answer.No;
9
10// javascript
11var Answer;
12(function (Answer) {
13 Answer[(Answer["No"] = 0)] = "No";
14 Answer["Yes"] = "Yes";
15})(Answer || (Answer = {}));
16
17const yes = "Yes"; /* Answer.Yes */
18const no = 0; /* Answer.No */
Ambient enums
1// typescript
2declare enum Answer {
3 No = 0,
4 Yes = "Yes",
5}
6
7const yes = Answer.Yes;
8const no = Answer.No;
9
10// javascript
11const yes = Answer.Yes;
12const no = Answer.No;
Ambient const enums
1// typescript
2declare const enum Answer {
3 No = 0,
4 Yes = "Yes",
5}
6
7const yes = Answer.Yes;
8const no = Answer.No;
9
10// javascript
11const yes = "Yes"; /* Answer.Yes */
12const no = 0; /* Answer.No */
Object + as const
1// typescript
2const ANSWER = {
3 No: 0,
4 Yes: "Yes",
5} as const;
6
7const yes = ANSWER.Yes;
8const no = ANSWER.No;
9
10// javascript
11const ANSWER = {
12 No: 0,
13 Yes: "Yes",
14};
15
16const yes = ANSWER.Yes;
17const no = ANSWER.No;
Union types
1// typescript
2type Answer = 0 | "Yes";
3
4const yes: Answer = "Yes";
5const no: Answer = 0;
6
7// javascript
8const 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:

ApproachEnumConst enumConst enum + preserveConstEnumsObject + as constUnion type
Numeric values126441128044
Heterogeneous values124481178348
String values116491088349

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! 👏

  1. How do the different enum variants work in TypeScript? | Stack Overflow

  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. Ambient enums | TypeScript Docs

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

  10. JavaScript reserved keywords

  11. Proposal for ECMAScript enums | GitHub

  12. with-or-without-enums-bundle-size-impact | GitHub

typescript

Comments

Alexey Berezin profile image

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