The Origins of TypeScript Enums
TypeScript 0.9, released around 2013, brought enums into the language as a type-safe and readable replacement for plain JavaScript constants and the infamous "magic numbers or strings" that developers often used to define a related set of values. When you rely on string or numeric literals scattered throughout your codebase, you're inviting typos that lead to subtle bugs. Renaming such values later becomes a tedious hunt-and-replace exercise where you can easily overlook an occurrence. Defining a constant improved on raw 'magic values,' but still fell short of the type safety TypeScript aspires to deliver, so enums were introduced to fill that gap.
The language architects behind TypeScript drew inspiration from strongly typed languages like C# and Java, where enums are a standard construct. Adopting enums made sense as a way to solve the JavaScript constant problem and resonated with developers transitioning from these statically typed ecosystems, easing TypeScript's adoption curve.
Although enums were a step up from plain JavaScript practices, their limitations surfaced quickly. Since JavaScript has no native enum concept, TypeScript enums are simply syntactic sugar wrapped around regular objects. That means they can never fully match the behavior of enums in true statically typed languages.
Peeking Under the Hood of TypeScript Enums
To grasp the weaknesses of TypeScript enums, it helps to look at what happens during compilation. TypeScript operates as a compile-time tool: type checking, code analysis, and error detection happen before the code is transformed and stripped down to JavaScript. Most TypeScript syntax is erased in this process, but enums are a notable exception. Along with namespaces, runtime-heavy modules, parameter properties, and non-ECMAScript import/export assignments, enums produce real JavaScript that lands in your runtime bundles.
This departure from TypeScript's usual compile-time-only approach is arguably the first and most significant concern. Let's examine how the TypeScript compiler (tsc) handles enums by looking at a practical example.
Consider this Status enum:
enum Status {
Success,
Pending,
Failed,
}
The compiler transforms it into 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 = {}));
Executing this IIFE yields the following object:
{
0: 'Success',
1: 'Pending',
2: 'Failed',
Success: 0,
Pending: 1,
Failed: 2
}
Numeric enums get a bi-directional mapping, which aids debugging and logging, but it also means extra generated code that you may not anticipate when you write a simple enum declarations. With only a handful of enums, the overhead is negligible. But scale that up to hundreds or thousands of enums in a large application, and the additional JavaScript starts to add up.
Now, let's look at a string-based Status enum:
enum Status {
Success = "success",
Pending = "pending",
Failed = "failed",
}
Just like before, the compiler converts it into an IIFE:
var Status;
(function (Status) {
Status["Success"] = "success";
Status["Pending"] = "pending";
Status["Failed"] = "failed";
})(Status || (Status = {}));
When you run this, the result differs from what you saw with the numeric version:
{
Success: 'success',
Pending: 'pending',
Failed: 'failed'
}
The numeric enum creates a bi-directional object, while the string enum produces a unidirectional one. On the surface, this looks like a minor inconsistency. But TypeScript enums are full of these kinds of subtle differences, which can lead to unpredictable usage patterns and bugs when developers aren't aware of every nuance.
Before going deeper into the issues and potential alternatives, it's worth considering another option that sits alongside regular numeric and string enums: const enums. Let's see how they work during compilation.
You declare a const enum simply by adding the const keyword:
const enum Status {
Success = "success",
Pending = "pending",
Failed = "failed",
}
Unlike standard TypeScript enums, const enums are fully erased at compile time. Only the specific property values you reference remain in the output. For instance, if you write const status = Status.Success;, the compiler replaces that with a plain JavaScript variable assignment (var status = 0; /* Success */) and discards the enum object entirely. As with most design decisions in software, there's a trade-off between regular and const enums, and each variant has its rightful use case if you're committed to using enums.
Now that you've seen how enums work behind the scenes, let's look at the problems they can introduce in your TypeScript code.
What Makes TypeScript Enums Problematic?
Before deciding whether TypeScript enums deserve a place in your codebase, it's essential to understand the concerns critics raise. Are these objections legitimate or exaggerated? This section breaks down the most frequently cited drawbacks so you can form an informed opinion.
Enums Are Non-Erasable TypeScript
As we discussed earlier, TypeScript enums fall into the category of non-erasable syntax — they survive compilation and appear as actual JavaScript in your production bundles. For many developers opposed to enums, this is the central grievance. Let's evaluate whether this is genuinely problematic or somewhat overstated. The non-erasable nature of enums gives rise to several distinct concerns.
- It violates the fundamental premise of TypeScript as a compile-time tool that leaves no trace on your runtime code. While the practical impact may be small in some cases, for many developers this boils down to a philosophical objection.
- It adds weight to your JavaScript bundles. In smaller projects, this may go unnoticed, but in large-scale enterprise applications containing hundreds or thousands of enums, the accumulation becomes real. Numeric enums are particularly costly — roughly every 50 enum values adds about 1 Kb to your bundle, translating to approximately 1–2 milliseconds of extra load time in the browser. On slower connections like 3G, that figure climbs toward 5 ms. Across thousands of enums with numerous values, you're looking at substantial added kilobytes and delays. Additionally, enums generate an IIFE (immediately invoked function expression), which carries a small performance and memory overhead compared to plain JavaScript objects. While these increments may appear negligible in isolation, reducing bundle size and load time wherever possible is generally good practice — even minor improvements can meaningfully affect conversion rates and user retention.
- Not every ecosystem, toolchain, or runtime supports non-erasable TypeScript. This concern has gained prominence recently — Node.js now offers native TypeScript execution, but only for erasable syntax, meaning enums are unsupported. Similarly, tools like ts-blank-space and Amaro share this restriction. In response, TypeScript 5.8 introduced the
--erasableSyntaxOnlyflag. Enabling this flag causes tsc to raise errors when encountering non-erasable constructs such as enums. Since many of us work primarily with Angular, it's worth remembering that relying on features with inconsistent support can be risky — you may not realize when they'll fail.
With the non-erasable code concerns outlined, let's turn to another significant issue: the asymmetry between string and numeric enums.
String and Numeric Enums Behave Differently
During compilation, string and numeric enums take divergent paths. String enums become unidirectional objects, while numeric enums produce bidirectional ones. This distinction matters because bidirectional objects allow reverse lookups — retrieving the key name from a value. Reverse lookups work for numeric enums but are unavailable for string enums. Here's what a reverse lookup looks like with a numeric enum:
enum Status {
Success,
Pending,
Failed,
}
const enumKey = Status[Status.Success]; /* Success */
Attempting the same operation with a string enum produces a compiler error:
enum Status {
Success = "success",
Pending = "pending",
Failed = "failed",
}
const enumKey =
Status[
Status.Success
]; /* ❌ Property 'success' does not exist on type 'typeof Status' */
What's more, reverse lookups on numeric enums accept any number — even values that don't correspond to any defined property:
enum Status {
Success,
Pending,
Failed,
}
const status = Status[100]; // This works, but is undefined
The differences extend beyond compilation output and reverse lookups — they also affect how you write the enums themselves. You may have noticed we didn't assign explicit values to the numeric enum. Numeric enums assign values automatically, starting at zero and incrementing by one for each subsequent member. Alternatively, you can assign one or more values manually. When you specify a numeric value and leave subsequent members unassigned, TypeScript auto-increments from your assigned value.
enum Status {
Success, // 0
Pending = 3, // 3
Failed, // 4
}
enum Direction {
Up, // 0
Down = 20, // 20
Left = 3, // 3
Right, // 4
}
This seems straightforward, but there's a subtle trap: assigning a value that collides with an auto-generated one.
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right = 1, // 1
Diagonal, // 2
}
That assignment corrupts the enum entirely, producing duplicate values. Down and Right both resolve to 1, while Left and Diagonal both resolve to 2. Unsurprisingly, this behavior invites bugs when developers aren't aware of the auto-generated numbers. Not only do you get duplicated values, but reverse lookups break as well — Direction[Direction.Left] returns Diagonal instead of Left. This points to another enum flaw: TypeScript doesn't warn when you create enums with duplicate values, and for numeric enums, this also causes missing entries in reverse lookups.
String enums, by contrast, require explicit values for every member, reducing the potential for surprise. However, there's no way to enforce that an enum is purely string or purely numeric — you can freely mix both types. This opens the door to partially assigned enums:
enum Direction {
Up = 'up', // up
Down, // 0
Left // 1
Right // 2
}
Again, unexpected behavior can result if the developer consuming this enum assumes that because the first member got a string value, the remaining unassigned members (Down, Left, Right) will also receive strings. If you decide to stick with enums, avoid mixing string and numeric values. My personal recommendation is to prefer string enums, or at minimum, always assign explicit values.
You might think we've covered all the discrepancies, but there's more.
Type-checking behaves differently for each enum kind. String enums employ nominal typing (sometimes called named typing) — type validity is checked against the explicit enum type. This means you must reference the enum itself rather than passing the raw string value, even if that value is legitimate:
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
Numeric enums, however, rely on structural typing (also known as duck typing or shape typing). The type checker validates based on the shape of the value rather than its declared identity. Consequently, you can pass a plain number directly where a numeric enum is expected:
enum Status {
Success,
Pending,
Failed,
}
function setStatus(status: Status) {
// set status
}
setStatus(0); // ✅ This works
setStatus(Status.Success); // ✅ This works
These numerous distinctions between string and numeric enums create ample opportunity for confusion, particularly in larger teams where not every developer may be familiar with all the edge cases. Code reviews can miss these subtle issues. Beyond non-erasable code, bundle bloat, and the string-versus-numeric divergence, more problems exist — let's keep exploring.
Enums Support Declaration Merging
Ideally, an enum should be entirely immutable. For the most part, that holds true — except for one feature: declaration merging. What is declaration merging? It's when you declare two entities with the same name, and the compiler combines them into a single definition. Here's an illustration:
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 might seem useful at first glance, it opens the door to inconsistent behavior, surprising values, and runtime failures. The declare module approach can produce particularly unexpected results. When you extend an enum in a specific file via declare module, the newly added members exist only at the type level — they're never compiled into JavaScript. Consider the following code:
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've used declaration merging to add Guest and SuperAdmin to a Role enum. Within the file containing the merge, these values behave like ordinary enum members — the logIfUserIsGuest function references Role.Guest, and the function call uses Role.SuperAdmin. The compiler accepts this without complaint. Yet at runtime, both Role.Guest and Role.SuperAdmin evaluate to undefined because they were never emitted to JavaScript. The function call logIfUserIsGuest(Role.SuperAdmin) would log Guest user in this scenario.
This pattern should never be used, which raises the question — why is it even permitted? True, a thorough code review should catch this, but attention can waver, especially during large merges where such issues can slip through.
The list of enum-related issues continues. At the time of writing, the TypeScript GitHub repository tracks 72 open issues tagged as bugs related to enums. Most are relatively minor, but they're worth reviewing. If you're still inclined to use TypeScript enums after learning about their flaws, I suggest implementing lint rules to enforce safer, more consistent usage. Below are three rules worth considering: one requiring explicit value assignment, one prohibiting mixed numeric and string members, and one detecting duplicate values.
{
"rules": {
"@typescript-eslint/prefer-enum-initializers": "error",
"@typescript-eslint/no-mixed-enums": "error",
"@typescript-eslint/no-duplicate-enum-values": "error"
}
}
Now that you have a clearer picture of the problems surrounding enums, let's examine const enums — you might believe they offer a solution.
Are Const Enums Actually Better?
At first glance, const enums appear to address several of the issues. Declaration merging isn't possible with const enums, and they don't inflate bundle size since only the values actually used are inlined as const properties. However, they do still compile to JavaScript — even if it's just a minimal const assignment — which means they remain incompatible with libraries and runtimes that accept only erasable TypeScript. Furthermore, the string-versus-numeric enum differences persist with const enums. Adding to that, const enums come with their own set of problems. Many of the 72 open issues on GitHub involve const enums, and the official TypeScript documentation includes a section dedicated to warning about common pitfalls. Let's explore those pitfalls in greater depth.
Working with const enums may feel straightforward initially, but hidden complications lurk — especially when sharing code across projects or publishing .d.ts files (which occurs when you run tsc --declaration).
Ambient Const Enums Conflict with isolatedModules:
The isolatedModules setting in tsconfig.json guarantees that each TypeScript file can be transpiled independently, a requirement when using tools like Babel or SWC that compile .ts files one at a time. An ambient enum is one declared in a .d.ts file — these declaration files describe types and values that are implemented elsewhere.
For instance, here's an ambient const enum declared in a .d.ts file:
// directions.d.ts
declare const enum Direction {
Up,
Down,
Left,
Right,
}
It informs TypeScript about the enum's shape without providing any concrete implementation in a .js file. Libraries and modules use this pattern to declare enums without shipping their implementation. The trouble arises because const enums are meant to be inlined, yet ambient const enums lack the actual values at the point where they're needed.
// directions.d.ts
declare const enum Direction {
Up,
Down,
}
// app.ts
const dir = Direction.Up;
This situation triggers an error when isolatedModules: true is active:
"Cannot use 'const enum' with --isolatedModules because values cannot be computed."
If you're developing a library that publishes .d.ts files containing const enums, downstream users with isolatedModules enabled may be completely unable to use them. That's a serious compatibility concern.
Version Conflicts Fail Silently:
Because const enum values are inlined during compilation, they're baked directly into your JavaScript. If your project compiles against version A of a dependency, the enum values from that version are permanently embedded in your output. If at runtime the project loads version B of that same dependency (which may define different values), your code is operating with incorrect constants.
This version skew can occur for various reasons. In monorepo or multi-package setups, your application might compile against version 1 of a library, inlining its const enum values. If another part of the project installs a different version, the package manager (npm, Yarn) might hoist the other version to the root, effectively swapping in the wrong runtime values. Similarly, when publishing libraries or deploying to shared environments, your code might be built against one dependency version but execute against a different one. These mismatches can silently corrupt logic when inlined values no longer align with actual runtime values.
The resulting bugs are particularly frustrating: if statements evaluate down the wrong branch because the enum value your code assumes doesn't match reality. These issues are hard to detect because they won't surface in tests — assuming your test and build environments use the same dependency version. The problem only materializes when compile-time and runtime versions diverge.
Runtime Errors Due to Unresolved Imports:
When importsNotUsedAsValues: "preserve" is set in your tsconfig.json, TypeScript retains imports that are only referenced as const enum values. This is acceptable for regular enums. However, with ambient const enums, the corresponding JavaScript files may not exist — because .d.ts files produce no .js output.
As a result, your application might crash at runtime while attempting to import a module that doesn't exist.
One potential workaround is type-only imports, which are designed to be stripped by the compiler. Unfortunately, type-only imports don't currently work with const enum values. You're caught between preserving imports that lead to runtime failures or omitting them in ways incompatible with const enums.
Having reviewed the most prominent pitfalls with const enums, you should know that more issues are documented on GitHub. However, the takeaway is clear: const enums don't solve the underlying problems — and in some situations, they should be avoided even more diligently than regular enums.
Alternatives to Enums
Back when TypeScript 0.9 landed around 2013, enums were essentially the only type-safe way to group related values. Much has shifted in both JavaScript and TypeScript since then, and developers now have better options. The current TypeScript documentation even acknowledges that enums may no longer be necessary, pointing to alternatives that fit more naturally with modern JavaScript.
The recommended replacement is the as const object. In this pattern, you create a regular const object and attach an as const assertion. That assertion locks the object's properties as literal types rather than widening them to primitives. So a property holding 'Success' gets typed as exactly that string, not as a generic string. Here's how our earlier Status enum looks when expressed as an as const object:
const StatusEnum = {
Success: "Success",
Pending: "Pending",
Failed: "Failed",
} as const;
// StatusEnum.Success is equal to 'Success'
This object behaves like an enum, but it cannot serve directly as a type. You therefore need to define a separate type alongside it. To keep things clear, I prefer to name the object property with an Enum suffix so it's distinct from the type. The Status type itself is defined 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"
Why would you choose this over an enum?
For one, since it's just an object, you must spell out every value explicitly — no hidden numeric assignments are invented by the compiler. Your JavaScript output stays lean: the object you write is the object that compiles, with no surprising additions. All type annotations are erased during compilation, making this purely erasable syntax. That means it works in Node.js without a transpilation step and plays nicely with tooling that rejects non-erasable constructs.
There's also no need for lint rules that police which value types your enums mix. If you want to keep your object's values to strings, just append satisfies Record<string, string>:
const StatusEnum = {
Success: "Success",
Pending: "Pending",
Failed: "Failed",
} as const satisfies Record<string, string>;
Switching to numeric values is just as simple with satisfies Record<string, number>. With as const objects, the friction between string and numeric enums disappears — everything is handled uniformly and predictably. Since these are standard JavaScript objects, reverse lookups work for both string and numeric values, something you'd typically lose with string enums. And all the bundling problems tied to const enums are moot, because the object is real JavaScript that survives compilation untouched.
That addresses essentially every flaw of enums and const enums while keeping your bundle smaller. The one thing as const objects don't natively block is duplicate values, but since values are always written by hand, duplicates can't slip in silently the way they can with auto-generated numeric enums. That's an acceptable trade-off: occasional written duplicates aren't a real problem; the real issue is accidental duplication you never asked for.
One more benefit: as const objects accept literal values directly, as long as they match one of the defined entries (similar to numeric enums, unlike 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
Whether being able to pass Success directly rather than referencing the object is a feature or a weakness is debatable. Generally speaking, requiring the enum object for all Status values feels more disciplined. However, in several frameworks — Angular included — you can't reference imported values directly inside templates. If you want an enum value as a function argument in an Angular template, you first have to expose it as a component property, which adds a (very slight) amount of code. Meanwhile, passing the literal value directly in the template would be both type-safe and simpler. I'm honestly torn on which is better, but it's hardly enough reason to stick with enums, especially since numeric enums already behave this way.
Overall, the as const object is a stronger solution for type-safe grouped constants. It sidesteps every significant drawback of enums without introducing new problems of its own.
If you'd rather ban enums from your project entirely, a lint rule can do the job:
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'TSEnumDeclaration',
message: 'Avoid using TypeScript enums. Use `as const` objects instead.',
},
],
}
Alternatively, if you're on TypeScript 5.8 or newer, you can enable the erasableSyntaxOnly flag in your tsconfig.json. Be aware that this also disables other non-erasable constructs — which, in many cases, is probably a good thing.
Final Thoughts
TypeScript enums have served developers well, but they carry baggage: unexpected runtime behavior, bloated bundles, and quirks with string and numeric variants. The as const object is a modern, type-safe alternative that aligns with where JavaScript and TypeScript are today. Developers who switch to it escape the pitfalls of non-erasable syntax, inconsistent enum value handling, and declaration merging.
To push your codebase toward as const objects, consider adding lint rules or, if you're on TypeScript 5.8 and above, enabling the erasableSyntaxOnly flag. Both approaches encourage cleaner, more consistent code and improve both performance and maintainability over the long run.
At the end of the day, whether you use enums or as const objects is your call — but armed with the trade-offs laid out here, you can choose with confidence based on what your project actually needs.

