There is a lot of talk within the Angular and TypeScript community as a whole about the usage of enums. It seems to be a hill both proponents and opponents are willing to die on and defend harshly. Enums are a commonly used TypeScript feature that offers tremendous value and improved developer experience within our codebases, so we don't want to abandon them without good reason and alternatives. Let's dive into the reasons why we might need a new approach to handle enums and what these alternative approaches are. At the end of this article, you can make up your own mind and decide once and for all if you will continue using enums or switch to an alternative approach.

Why did TypeScript add Enums?

Enums were introduced in TypeScript 0.9 (circa 2013) to give developers a type-safe and readable alternative to plain JavaScript constants and "magic numbers or strings" for defining a grouped set of values. Using numeric and string literals directly in your code is error-prone and hard to refactor. Let's say you have three different statuses you're using within your code: "success", "pending", and "failed".

If you're using these statuses as string literals throughout your code, you can easily create a typo, resulting in a bug. Also, when you want to change one of these values at some point in the future, finding and renaming all instances within your code can become a cumbersome task where it's easy to miss an instance. Creating a constant was better than 'magic values', but lacked the type-safety TypeScript aims to provide, so to address this issue, TypeScript created enums.

The early design of TypeScript was heavily influenced by statically typed languages like C# and Java. Since enums are a common construct within these languages, it made sense for TypeScript to adopt enums as a solution for JavaScript constants and "magic numbers or strings". Furthermore, using enums would also appeal to developers who came from strictly typed languages, facilitating better adoption.

While the introduction of TypeScript enums was an improvement over the plain JavaScript approaches for sure, the shortcomings of TypeScript enums also became apparent early on. As there is no such thing as enums in JavaScript, and it's just syntactic sugar, they never could live up to the enums we know and love from true statically typed languages.

Unveiling TypeScript Enums

To better understand the shortcomings of typeScript enums, let's start by lifting the veil and explore what happens with TypeScript enums during the compilation process. TypeScript is a compile-time tool, and after performing the type checking, code analysis, and error detection, all TypeScript code is erased during the compilation process. Enums (namespaces, modules with runtime code, parameter properties, and Non-ECMAScript import and export assignments) are an exception to this rule and result in compiled JavaScript code that ends up in your runtime JavaScript bundles. The fact that enums breaks the TypeScript paradigm and results in compiled JavaScript code inside your bundles is actually the first and maybe the largest issue. Before we dive into why this is an issue, let's see how the TypeScript compiler (tsc) compiles enums.

Let's take the following Status enum as an example:

enum Status {
  Success,
  Pending,
  Failed,
}

TypeScript will compile enums to an immediately invoked function expression (IIFE):

var Status;
(function (Status) {
  Status[(Status["Success"] = 0)] = "Success";
  Status[(Status["Pending"] = 1)] = "Pending";
  Status[(Status["Failed"] = 2)] = "Failed";
})(Status || (Status = {}));

When you run this IIFE, it generates the following object:

{
    0: 'Success',
    1: 'Pending',
    2: 'Failed',
    Success: 0,
    Pending: 1,
    Failed: 2
}

As you can see, for numeric enums the TypeScript compiler creates an object with bi-directional mapping. While this makes debugging and logging easier, it also generates additional code you might not be aware of when defining your enum. If you only have a few enums, this might not be a significant concern, but the extra code can add up considerably when developing large software with hundreds or even thousands of enums.

Now let's examine a string-based Status enum:

enum Status {
  Success = "success",
  Pending = "pending",
  Failed = "failed",
}

Just as before, the TypeScript compiler will compile this code to an IIFE:

var Status;
(function (Status) {
  Status["Success"] = "success";
  Status["Pending"] = "pending";
  Status["Failed"] = "failed";
})(Status || (Status = {}));

When you run this IIFE, you'll notice the output differs from the numeric enum:

{
    Success: 'success',
    Pending: 'pending',
    Failed: 'failed'
}

A bi-directional object was created for the numeric enum, whereas a unidirectional object was created for the string enum. While this may seem like a slight discrepancy, there are many nuanced differences in TypeScript enums. These nuances can lead to inconsistent usage and potential bugs when developers aren't fully aware of these subtleties.

To throw another alternative into the mix, besides numeric and string-based enums, there are also const enums. Before we start to dive deeper into the potential issues of using enums and the proposed alternative solutions, let's see what const enums are and how they are handled during the compilation process.

You define a const enum by simply prefixing it with const. Below, we can see the Status enum we used before as a const enum:

const enum Status {
  Success = "success",
  Pending = "pending",
  Failed = "failed",
}

Contrary to regular TypeScript enums, const enums are completely erased at compile-time, only leaving the values you use within your code, so if you define the following value within your code const status = Status.Success;, the tsc compiles it into a simple JavaScript variable (var status = 0; /* Success */) and removes the enum object itself. As with most things in software, there are trade-offs between regular and const enums, and each has its place if you insist on using TypeScript enums.

Now that we unveiled TypeScript enums and you should better understand how they work behind the scenes, let's dive into the potential issues of enum usages in TypeScript.

So what exactly is the problem with TypeScript Enums?

To make up your mind on whether you should or shouldn't use TypeScript enums, you need a good understanding of the potential issues that are associated with them. What are the arguments opponents of enums have, and are they valid concerns, or is it something you can ignore? In this section, we will go over the most common arguments against using TypeScript enums and provide you with the information you need to make up your mind on the matter.

Enums are non ereasable TypeScript code

As seen in the previous section, TypeScript enums are non-erasable code and will be compiled into JavaScript code, which ends up in your application bundles. For most opponents of TypeScript enums, this is the main issue they have with them. Let's examine if this actually is a problem or if it's overblown a bit. The problem of non-erasable code can be divided into multiple small issues.

  1. It goes against the core concept of TypeScript, which is a compile-time library that has no overhead on your production code. The fact that enums aren't erased can be a valid concern in some cases, but more so, it's a matter of principle for most developers.
  2. It increases your application bundles. This might not be an issue for small applications, but if you have a large enterprise environment with hundreds or even thousands of enums, it can start to add up. More so when using numeric enums. For every approximate 50 enums value, you will add 1 Kb to your JS bundle, resulting in about ~1-2 milliseconds in additional load time in the browser. On slow (3G) connections, this can run up to ~5 ms. If you have thousands of enums with many values, you can end up adding quite some additional Kb and ms to your bundles and load times. There will be a small additional impact on performance and memory usage because enums generate an IIFE versus alternatives that use simple JavaScript objects. In many cases, this additional load time and bundle size might not be a huge issue. Still, in general, we want to reduce bundle sizes and load time as much as possible, and slight differences can impact conversions and user retention quite a lot!
  3. Not all libraries, languages, and tools support non-erasable TypeScript code. This issue has become more relevant recently, most notably because Node.JS added support to run TypeScript files directly, yet this only works for erasable syntax, so enums will not work in this scenario. Other tools like ts-blank-space and Amaro have the same limitation and only with with erasable TypeScript syntax. Because of this, in TypeScript version 5.8 a new flag --erasableSyntaxOnly was added. When enabling this flag, the tsc will throw an error when you use non-erasable code like enums. I know we are mostly Angular developers here, but using something that doesn't always work can be dangerous because you might not be aware when it does and doesn't work.

Now that you know the problems associated with non-erasable TypeScript code, let's look at the next issue: inconsistencies between string and numeric enums.

String and Numeric Enums aren't the same!

We've already seen that string and numeric enums aren't handled the same way during the compilation process. String enums will be compiled into a unidirectional object, whereas a numeric enum will be compiled into a bi-directional object. Because numeric enums are compiled into a bi-directional object, you can use reversed lookups on numeric enums, but this isn't an option on string enums. Below is a reversed lookup on a numeric enum:

enum Status {
  Success,
  Pending,
  Failed,
}

const enumKey = Status[Status.Success]; /* Success */

Now, if we try to do the same with a string-based enum, the compiler will throw an error:

enum Status {
  Success = "success",
  Pending = "pending",
  Failed = "failed",
}

const enumKey =
  Status[
    Status.Success
  ]; /* ❌ Property 'success' does not exist on type 'typeof Status' */

To make things more confusing, you can do a reversed lookup on the numeric enum with any number, even if it's not inside the enum:

enum Status {
  Success,
  Pending,
  Failed,
}

const status = Status[100]; // This works, but is undefined

Besides the difference in the output and reverse lookups, there is also a difference in how you need to define the enums. You may already have noticed that we didn't assign any values for the numeric enum. Numeric enums will assign their values automatically, starting at zero and auto-incrementing by 1 to get the next value. Alternatively, you have the option to assign one or more values. If you assign a number value yourself, and there are non assigned keys after that, TypeScript will start to auto-incremented from the number value you assigned yourself.

enum Status {
  Success, // 0
  Pending = 3, // 3
  Failed, // 4
}

enum Direction {
  Up, // 0
  Down = 20, // 20
  Left = 3, // 3
  Right, // 4
}

This seems simple enough, but there is one small gotcha, and that is if you assign a value that has already been auto-generated:

enum Direction {
  Up, // 0
  Down, // 1
  Left, // 2
  Right = 1, // 1
  Diagonal, // 2
}

The above-value assignment fully messes up your enum, resulting in two double-value notations. Down and Right both have a value of 1, and Left and Diagonal both have a value of 2. As you might imagine, this behavior can lead to some unexpected bugs if a developer uses this without being aware of the generated values. Not only did you end up with duplicated values, the reversed lookup on this enum will also not work anymore Direction[Direction.Left] will result in Diagonal instead of Left. This can be seen as its own enum-related issue; there will be no warning or error when you create enums with duplicated values, and for a numeric enum, this will also lead to missing values on the reversed lookup.

Contrary to numeric enums, string enums need to be explicitly assigned a value, leaving less room for unexpected behavior, as we demonstrated with the numeric enum. But there is a catch: you can't enforce an enum to be purely string or number-based, meaning you can mix and match string and numeric values in an enum. Because of that, you can assign some values with a string and leave open others:

enum Direction {
  Up = 'up',      // up
  Down,           // 0
  Left            // 1
  Right           // 2
}

Again, this might result in unexpected behavior if the developer who implements it isn't fully aware of this. They might expect that the Down, Left, and Right keys will also get a string value assigned because the first value was assigned with a string. If you do want to use enums, don't mix string and numeric values, and I would say prefer string enums or at least always assign explicit values on your enums.

You might think we're done with the differences between string and numeric enums, but there is more...

Type-checking is also handled differently for string and numeric enums. String-based enums use named typing (also known as nominal typing) and check for type validity on explicit declarations, meaning you need to use the enum and can't directly provide the value even if it's a valid value:

enum Status {
  Success = "success",
  Pending = "pending",
  Failed = "failed",
}

function setStatus(status: Status) {
  // set status
}

setStatus("success"); // ❌ Argument of type '"success"' is not assignable to parameter of type 'Status'.
setStatus(Status.Success); // ✅ This works

On the other hand, numeric enums use structural typing (also known as duck typing or shape typing) and type-check based on the structure or shape of an object rather than its explicit identity or name. This means that for a numeric enumn, you can directly pass in the value instead of using the enum:

enum Status {
  Success,
  Pending,
  Failed,
}

function setStatus(status: Status) {
  // set status
}

setStatus(0); // ✅ This works
setStatus(Status.Success); // ✅ This works

As you can see, there are many differences between string and numeric enums, and all these nuances can easily lead to issues, especially when working on a large team. Not every developer will know all these nuances, and things can slip through the cracks in reviews. Besides non-erasable code, suppose bundle increases and many differences between numeric and string-based enums, there are other issues related to TypeScript enums, so let's continue to explore.

You can do a declaration-merge with Enums

I would say if you define an enum, you want it to be fully immutable. For the most part, this is also the case, except that you can do a declaration merge with enums. You might be asking what even is a declaration merge. A declaration merge is when you define two objects with the same declaration (name), and the compiler will merge the two objects for you. Below, you can see an example:

enum Status {
  Success = "Success",
  Pending = "Pending",
  Failed = "Failed",
}

enum Status {
  Processing = "processing",
}

//Alternatively you can also do a declaration merge in other files like this:
declare module "./path-to-enum-export" {
  enum Status {
    Inactive = "inactive",
  }
}

While this may seem useful initially, it can lead to inconsistent usage, unexpected values, and runtime errors. The declare module alternative can result in some unexpected behavior. When you extend an enum in a specific file using the declare module approach, the added value is never compiled to JavaScript, and it's only a type. As an example, we can take the code below:

declare module "./path-to-enum" {
  enum Role {
    Guest = "guest",
    SuperAdmin = "superAdmin",
  }
}

function logIfUserIsGuest(role: Role) {
  if (role === Role.Guest) {
    console.log("Guest user");
  }
}

logIfUserIsGuest(Role.SuperAdmin);

In this example, we did a declaration merge on a Role enum and added a Guest and SuperAdmin. Inside the file where we do the declaration merge, we can use these two merged values as if it were a regular enum value. As you can see, inside the logIfUserIsGuest function, we use Role.Guest, and inside the function call, we use Role.SuperAdmin. The compiler will allow this and will not complain, yet both the Role.Guest and the Role.SuperAdmin are undefined because they are never compiled into JavaScript code. Our logIfUserIsGuest(Role.SuperAdmin) function call will log Guest user in this scenario.

This should never be done, which begs the question, why is it even allowed? It's true, this should be caught during a code review, but sometimes we don't pay attention that well, especially if it's a large merge, things can slip thought the mazes.

There are plenty more issues related to TypeScript enums. At the time of writing, there are 72 open issues related to enums on TypeScript GitHub repository. Most of these are minor issues, but it's worth investigating them. If you still want to use TypeScript enums at this point, I recommend using some lint rules to make their usage safer and more consistent. Below are three rules I can recommend: one to enforce explicit value initialization, one to prevent mixing numeric and string enums, and one that prevents duplicate enum values.

{
  "rules": {
    "@typescript-eslint/prefer-enum-initializers": "error",
    "@typescript-eslint/no-mixed-enums": "error",
    "@typescript-eslint/no-duplicate-enum-values": "error"
  }
}

Now that you better understand some of the issues related to enums let's look at const enums, as you might feel they are the better solution.

Are const Enums any better?

At first glance, const enums might seem like a better alternative. You can't do a declaration merge on const enums, and they don't bloat your bundle sizes as only used values are combined into const properties. The code is still compiled to JS; however, even if it's just a simple const property, you can't use them with libraries and runtimes requiring only erasable TypeScript code. Also, the differences between numeric and string-based enums remain with const enums. Besides that, const enums have their own set of issues. Many of the 72 open issues on GitHub are related to const enums and inside the TypeScript documentation there is a special section dedicated to warning you about common pitfalls when working with const enums. Let's explain these common pitfalls in a bit more detail.

Using const enum in TypeScript can seem simple at first, but there are some tricky issues you need to watch out for. These problems mostly show up when you share code between projects or publish .d.ts files (which happens when you use tsc --declaration to generate type declarations).

Ambient Const Enums don't work with isolatedModules:

The isolatedModules setting in tsconfig.json makes sure that each TypeScript file can be compiled independently. This is often needed if you're using tools like Babel or SWC, which compile .ts files one at a time. An ambient enum is one that's defined in a .d.ts file—these are declaration files which only describe types and values that are defined somewhere else.

For example, this is an ambient const enum in a .d.ts file:

// directions.d.ts
declare const enum Direction {
  Up,
  Down,
  Left,
  Right,
}

It tells TypeScript what the enum looks like, but it doesn’t actually define the values in any .js file. It’s used when a library or module wants to declare what enums exist without including their actual implementation. When using ambient const enums, a problem arises because const enums must be inlined, and ambient const enums don't contain the actual values when it's needed.

// directions.d.ts
declare const enum Direction {
  Up,
  Down,
}

// app.ts
const dir = Direction.Up;

With isolatedModules: true, this will error out:

"Cannot use 'const enum' with --isolatedModules because values cannot be computed."

So if you’re building a library and you publish .d.ts files that include const enums, your users might not be able to use them at all if they have isolatedModules turned on. That’s a big compatibility issue.

Version Mismatches Can Break Code Silently:

Because const enum values are inlined at compile time, they get baked into your JavaScript during build. That means if your project compiles using version A of a dependency, the enum values from that version are embedded in your code. But at runtime, if the project loads version B of that same dependency (which might have different enum values), then your code is now using the wrong values.

It's possible to compile your code using one version of a dependency and end up running it with another due to how package managers and build systems resolve modules. For example, in a monorepo or multi-package setup, your app might compile against version 1 of a library, inlining its const enum values during the build. However, if another part of the project depends on version 2 of that same library, the package manager (like npm or Yarn) might hoist version 2 to the top level, effectively replacing version 1 at runtime. Similarly, if you're publishing libraries or using shared environments, your code could be built with one version of a dependency but deployed into a system that loads a newer or older one. This mismatch can silently break logic when inlined const enum values no longer match the actual runtime values.

This can lead to very confusing bugs, like if statements go down the wrong path because the enum value your code expects doesn't match what it’s actually using at runtime. These bugs are especially tricky because they won’t show up in tests, if your tests and builds are using the same dependency version. They only appear when there’s a mismatch between the compile-time and runtime versions.

Runtime Errors from Unresolved Imports:

If you use importsNotUsedAsValues: "preserve" in your tsconfig.json, TypeScript keeps imports that are only used for const enum values. That’s fine for regular enums. But with ambient const enums, the actual JavaScript files might not exist at runtime, since .d.ts files don’t generate any .js.

This means your app could crash at runtime because it’s trying to import a file that doesn’t exist.

One way to avoid this is to use type-only imports, which are meant to be stripped out by the compiler. But unfortunately, type-only imports can’t be used with const enum values right now. So you’re stuck between keeping imports that break at runtime or removing them in a way that doesn’t work with const enums.

Now you know more about the most common pitfalls when working with const enums, there are more listed on GitHub, but I think the message is clear, const enums aren't the solution and in some scenarios should be avoided even more than regular enums.

If not Enums, then what else?

When TypeScript 0.9 (circa 2013) introduced enums there wasn't really any alternative for defining a grouped set of values in a type-safe approach. But since these days, both JavaScript and TypeScript have changed a lot, and now there are alternatives. If you look at the enum documentation of TypeScript, they also express that in modern TypeScript, you might not need enums anymore, as there now are alternative approaches that are more in line with the state of JavaScript.

The approach TypeScript recommends as an alternative is the as const object. With this approach, you simply define a const object and do an as const assertion on the object to seal it and tell TypeScript the values are literal values instead of primitives, meaning, if you define a string value, it will type it as that value instead of a string. Below, you can see an example of our Status enum on an as const object form:

const StatusEnum = {
  Success: "Success",
  Pending: "Pending",
  Failed: "Failed",
} as const;

// StatusEnum.Success is equal to 'Success'

This object will give you your enum, but you can't use this as a type, so in addition to the object, you also need to define a type. Because of that, I like to suffix my as const property names with Enum to indicate it is the enum and not the Type. In addition to the object, you can create the Status type like this:

type Status = (typeof StatusEnum)[keyof typeof StatusEnum];

// The above code is equal to manually defining a union type:
// type Status = "Success" | "Pending" | "Failed"

You might be asking yourself, why is this better?

For starters, because it's an object, you are required to define explicit values, and no magic numeric values are assigned by the TypeScript compiler. There is also no unexpected bloat of your JavaScript bundles, the object you defined is a regular JavaScript object and will remain unchanged during the compilation, what you see is what you get. All the type information you defined will be stripped during the compilation process. This approach only uses erasable syntax, so it can be used in NodeJs without a TypeScript compiler in between or with libraries and other tools that do not handle non-erasable code.

You also don't need a lint rule to restrict the types of your enum values and prevent mixing numeric and string values. You simply append your as const object with satisfies Record<string, string> as seen below:

const StatusEnum = {
  Success: "Success",
  Pending: "Pending",
  Failed: "Failed",
} as const satisfies Record<string, string>;

If you want to use numeric values instead, you can use this satisfies Record<string, number>. When using as const objects, there are also no inconsistencies between numeric and string values, everything will be handled equally and how developers are used to. Because you're using regular JavaScript objects, you can also do reverse lookups on both string and numeric enums when using the as const approach. All the issues related to const enums are also irrelevant for as const objects because you're defining JavaScript code, which will always be available in the compiled bundles.

That basically covers every pitfall of the enum and const enum with a smaller footprint on your bundle size. The only thing that can't be covered out of the box with as const objects is preventing duplicate values, but since you always have to define your values explicitly, it will not happen unexpectedly as it can with numeric enums. So this is not really an issue and having the option for when you actually need it isn't bad, what is bad is when it happens without your explicitly defining the values.

Lastly, with as const objects, you can provide valid literal values that are defined on your enum instead of using the enum object (similar to numeric enums, but opposed to string enums):

const StatusEnum = {
  Success: "Success",
  Pending: "Pending",
  Failed: "Failed",
} as const satisfies Record<string, string>;

type Status = (typeof StatusEnum)[keyof typeof StatusEnum];

function setStatus(status: Status) {
  // set status
}

setStatus("Success"); // ✅ This works

While Success is a valid value, and you can't supply invalid values if this is an advantage or disadvantage, it is debatable. In general, I would say it's better if you can only use the enum object to provide Status values, although, in a lot of frameworks, Angular being one of them, you can't directly use imported values inside your templates. So, if you want to use an enum value as a function param in your HTML template, you need to assign the enum to a property in your component class before you can use it inside the template. This requires an additional property on your component, further increasing your bundle size (granted very marginally), while you could type-safe pass in the value directly in the template as well. Personally, I'm a bit conflicted on this and can't make up my mind what is correct, but this isn't enough to favor enums over as const objects, especially because this is also how it works with numeric enums.

All and all I would say the as const object does a better job at providing a type-safe approach for defining a set of grouped const values. There are no real drawbacks and issues associated with it, while preventing all the issues associated with TypeScript enums.

If you want to prevent the usage of enums in your codebase, you can enforce this with a lint rule:

rules: {
  'no-restricted-syntax': [
    'error',
    {
      selector: 'TSEnumDeclaration',
      message: 'Avoid using TypeScript enums. Use `as const` objects instead.',
    },
  ],
}

Alternativly if you are using TypeScript 5.8 or higher, you can set the erasableSyntaxOnly flag inside your tsconfig.json, but this will also disable all other non erasable syntax (which is a good thing if you ask me).

Conclusion

In conclusion, while TypeScript enums have been a valuable tool for developers, they come with several pitfalls and limitations that can lead to unexpected behavior and increased bundle sizes. The introduction of as const objects provides a modern, type-safe alternative that aligns better with the current state of JavaScript and TypeScript. By using as const objects, developers can avoid many of the issues associated with enums, such as non-erasable code, inconsistencies between string and numeric enums, and declaration merging.

If you want to enforce the use of as const objects in your codebase, consider using lint rules or the erasableSyntaxOnly flag in TypeScript 5.8 or higher. This will help ensure a more consistent and efficient codebase, ultimately leading to better performance and maintainability.

Ultimately, the choice between enums and as const objects is up to you, but with the information provided in this article, you can make a more informed decision that best suits your project's needs.


Tagged in:

Articles

Last Update: April 29, 2025