The Code Review That Changed Everything
TL;DR
TypeScript enums compile to runtime code, adding bloat and lacking tree-shaking. Use const objects with 'as const' for better type safety, smaller bundles, and cleaner JavaScript.
Key Takeaways
- •TypeScript enums generate runtime JavaScript code, unlike other TypeScript features that are erased, leading to unnecessary bundle size.
- •Enums cannot be tree-shaken, so unused values are still included in the final bundle, unlike const objects which are more efficient.
- •Const objects with 'as const' provide stricter type safety using literal types, preventing bugs that enums might allow due to structural typing.
- •Migrating from enums to const objects is straightforward and improves code readability, compilation speed, and developer experience.
Tags
Three months ago, I submitted what I thought was a perfectly reasonable pull request. I had created a new UserRole enum to handle our permission system. Clean, type-safe, idiomatic TypeScript.
The senior engineer's review came back with one comment: "Please don't use enums."
I was confused. Enums are in the TypeScript handbook. They're taught in every course. Major codebases use them. What was wrong with enums?
Then he showed me the compiled JavaScript output.
I deleted every enum from our codebase that afternoon.
This article explains why TypeScript enums are one of the language's most misunderstood features—and why you should probably stop using them.
Part 1: The Enum Illusion
TypeScript sells itself as "JavaScript with syntax for types." The promise is simple: write TypeScript, get type safety, compile to clean JavaScript.
For most TypeScript features, this is true. Interfaces? Erased. Type annotations? Erased. Generics? Erased.
Enums? They become real runtime code.
This fundamental difference makes enums an anomaly in TypeScript—and a trap for developers who don't understand the compilation model.
The Simple Example
Let's start with something innocent:
enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
Pending = "PENDING"
}
function getUserStatus(): Status {
return Status.Active
}
Looks clean, right? Here's what actually ships to your users:
var Status;
(function (Status) {
Status["Active"] = "ACTIVE";
Status["Inactive"] = "INACTIVE";
Status["Pending"] = "PENDING";
})(Status || (Status = {}));
function getUserStatus() {
return Status.Active;
}
That's 9 lines of JavaScript for 5 lines of TypeScript.
But wait—it gets worse.
Part 2: The Numeric Enum Nightmare
String enums are bad. Numeric enums are a disaster.
enum Role {
Admin,
User,
Guest
}
You might expect this to compile to something simple. Maybe const Role = { Admin: 0, User: 1, Guest: 2 }.
Here's what you actually get:
var Role;
(function (Role) {
Role[Role["Admin"] = 0] = "Admin";
Role[Role["User"] = 1] = "User";
Role[Role["Guest"] = 2] = "Guest";
})(Role || (Role = {}));
What's happening here?
TypeScript is creating reverse mappings. The compiled object looks like this:
{
Admin: 0,
User: 1,
Guest: 2,
0: "Admin",
1: "User",
2: "Guest"
}
This allows you to do: Role[0] // "Admin"
Question: Did you ever need this feature?
In five years of professional TypeScript development, I have never once needed to look up an enum name from its numeric value. Not once.
Yet I've shipped this extra code to production hundreds of times.
Part 3: The Tree-Shaking Problem
Modern bundlers like Webpack, Rollup, and Vite have sophisticated tree-shaking capabilities. They can eliminate unused code with surgical precision.
Unless you're using enums.
The Problem
// types.ts
export enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
Pending = "PENDING",
Archived = "ARCHIVED",
Deleted = "DELETED"
}
// app.ts
import { Status } from './types'
const currentStatus = Status.Active
What you want: Just the string "ACTIVE" in your bundle.
What you get: The entire Status enum object plus the IIFE wrapper.
Enums cannot be tree-shaken because they're runtime constructs. Even if you only use one value, you get all of them.
Multiply this across dozens of enums in a real application, and you're shipping kilobytes of unnecessary code.
Part 4: The Better Alternative
So if enums are problematic, what should we use instead?
Solution 1: Const Objects with 'as const'
const Status = {
Active: "ACTIVE",
Inactive: "INACTIVE",
Pending: "PENDING"
} as const
Compiled JavaScript:
const Status = {
Active: "ACTIVE",
Inactive: "INACTIVE",
Pending: "PENDING"
}
That's it. No IIFE. No runtime overhead. Just a simple object.
Creating the Type
type Status = typeof Status[keyof typeof Status]
// Expands to: type Status = "ACTIVE" | "INACTIVE" | "PENDING"
Now you have:
- ✅ A runtime object for values
- ✅ A compile-time type for type checking
- ✅ Zero compilation overhead
- ✅ Tree-shakeable (if your bundler supports it)
Usage
// Works exactly like enums:
function setStatus(status: Status) {
console.log(status)
}
setStatus(Status.Active) // ✅ Valid
setStatus("ACTIVE") // ✅ Valid (it's just a string)
setStatus("INVALID") // ❌ Type error
Part 5: The Type Safety Advantage
Here's where it gets interesting: const objects provide BETTER type safety than enums.
The Enum Problem
enum Color {
Red = 0,
Blue = 1
}
enum Status {
Inactive = 0,
Active = 1
}
function setColor(color: Color) {
console.log(`Color: ${color}`)
}
// This compiles successfully:
setColor(Status.Active) // No error!
Why? Because TypeScript enums use structural typing. Both Color and Status are numbers, so TypeScript considers them compatible.
This compiled and shipped to production. It caused a bug that took hours to debug.
The Object Solution
const Color = {
Red: "RED",
Blue: "BLUE"
} as const
const Status = {
Inactive: "INACTIVE",
Active: "ACTIVE"
} as const
type Color = typeof Color[keyof typeof Color]
function setColor(color: Color) {
console.log(`Color: ${color}`)
}
// Type error:
setColor(Status.Active) // ❌ Type '"ACTIVE"' is not assignable to type '"RED" | "BLUE"'
The const object approach uses literal types, which are exact string values. TypeScript catches the error at compile time.
Const objects provide stricter type checking than enums.
Part 6: The Migration Path
Convinced? Here's how to migrate existing enums.
Step 1: Identify String Enums
These are the easiest to migrate:
// Before
enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE"
}
// After
const Status = {
Active: "ACTIVE",
Inactive: "INACTIVE"
} as const
type Status = typeof Status[keyof typeof Status]
Step 2: Convert Numeric Enums
For numeric enums, you need to preserve the numbers:
// Before
enum HttpStatus {
OK = 200,
NotFound = 404,
ServerError = 500
}
// After
const HttpStatus = {
OK: 200,
NotFound: 404,
ServerError: 500
} as const
type HttpStatus = typeof HttpStatus[keyof typeof HttpStatus]
Step 3: Update Usage
The good news? Usage stays mostly the same:
// Both work identically:
const status1: Status = Status.Active
const status2: HttpStatus = HttpStatus.OK
// Pattern matching still works:
switch (status) {
case Status.Active:
// ...
case Status.Inactive:
// ...
}
Step 4: Handle Edge Cases
If you're using reverse lookups (rare), you'll need to create an explicit reverse map:
const HttpStatus = {
OK: 200,
NotFound: 404
} as const
// Create reverse mapping only if needed:
const HttpStatusNames = {
200: "OK",
404: "NotFound"
} as const
HttpStatusNames[200] // "OK"
Part 7: The One Exception
Is there ever a valid reason to use enums?
Maybe: const enums
const enum Direction {
Up,
Down,
Left,
Right
}
const move = Direction.Up
Compiles to:
const move = 0 /* Direction.Up */
Const enums are inlined at compile time. They don't create runtime objects.
However:
- They don't work with
isolatedModules(required for Babel, esbuild, SWC) - They're being deprecated in favor of
preserveConstEnums - They're more complex than just using objects
My recommendation: Even for const enums, just use objects. Simpler is better.
Part 8: Real-World Impact
When we migrated our codebase from enums to const objects, here's what happened:
Before Migration
- Enums in codebase: 47
- Bundle size: 2.4 MB (minified)
- Enum-related code in bundle: ~14 KB
After Migration
- Enums in codebase: 0
- Bundle size: 2.388 MB (minified)
- Savings: 12 KB
"Only 12KB?"
Yes, but:
- It's 12KB we don't need to ship, parse, or execute
- Type safety improved (we caught 3 bugs during migration)
- Code became more readable (it's just JavaScript)
- New developers onboard faster (fewer TypeScript quirks)
Developer Experience Improvements
- Faster compilation: TypeScript doesn't need to generate enum code
- Better IDE performance: Fewer runtime constructs to track
- Easier debugging: Console logs show actual values, not enum references
- Simpler mental model: One less TypeScript-specific feature to remember
Part 9: Common Objections
"But enums are in the TypeScript docs!"
So are namespaces, and those are also considered legacy. The TypeScript team has acknowledged that enums were a mistake, but they can't remove them without breaking changes.
"My entire codebase uses enums!"
Migration is straightforward and can be done incrementally. Start with new code, migrate old code during refactors.
"Enums are more explicit!"
// Enum
enum Status { Active = "ACTIVE" }
// Object
const Status = { Active: "ACTIVE" } as const
The difference is minimal. The object version is actually more JavaScript-idiomatic.
"I need the type and the value!"
You get both with the const object pattern:
const Status = { Active: "ACTIVE" } as const // Runtime value
type Status = typeof Status[keyof typeof Status] // Compile-time type
"What about JSON serialization?"
Enums serialize to their underlying values anyway:
enum Status { Active = "ACTIVE" }
JSON.stringify({ status: Status.Active }) // {"status":"ACTIVE"}
Same as:
const Status =