The Code Review That Changed Everything

AI Summary10 min read

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

typescriptjavascriptperformancewebdev

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 = {}));
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Compiled JavaScript:

const Status = {
  Active: "ACTIVE",
  Inactive: "INACTIVE",
  Pending: "PENDING"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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"'
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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:
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Compiles to:

const move = 0 /* Direction.Up */
Enter fullscreen mode Exit fullscreen mode

Const enums are inlined at compile time. They don't create runtime objects.

However:

  1. They don't work with isolatedModules (required for Babel, esbuild, SWC)
  2. They're being deprecated in favor of preserveConstEnums
  3. 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:

  1. It's 12KB we don't need to ship, parse, or execute
  2. Type safety improved (we caught 3 bugs during migration)
  3. Code became more readable (it's just JavaScript)
  4. New developers onboard faster (fewer TypeScript quirks)

Developer Experience Improvements

  1. Faster compilation: TypeScript doesn't need to generate enum code
  2. Better IDE performance: Fewer runtime constructs to track
  3. Easier debugging: Console logs show actual values, not enum references
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

"What about JSON serialization?"

Enums serialize to their underlying values anyway:

enum Status { Active = "ACTIVE" }
JSON.stringify({ status: Status.Active }) // {"status":"ACTIVE"}
Enter fullscreen mode Exit fullscreen mode

Same as:

const Status = 

Visit Website