ShaharAmir
← Back to Blog
TypeScript9 min read

Stop Using TypeScript Enums: Here's Why

TypeScript enums seem convenient but come with hidden problems. Learn better alternatives that keep your code type-safe and bundle-friendly.

S
Shahar Amir

Stop Using TypeScript Enums: Here's Why

Enums are one of TypeScript's oldest features, borrowed from languages like C# and Java. At first glance, they seem like a convenient way to define a set of named constants. But dig deeper, and you'll find a mountain of problems that make them a poor choice for modern TypeScript projects.

In this post, I'll explain exactly why you should avoid enums and what to use instead.

What Are TypeScript Enums?

Before we tear them apart, let's understand what enums are:

typescript
123456789101112
enum Status {
Pending,
Approved,
Rejected
}
// Usage
const orderStatus: Status = Status.Approved;
if (orderStatus === Status.Approved) {
console.log("Order approved!");
}

Looks clean, right? Here's where the problems begin.

---

Problem #1: Enums Generate Surprising JavaScript

Unlike most TypeScript features that simply disappear at compile time, enums generate actual JavaScript code. And it's not pretty.

typescript
12345
enum Status {
Pending,
Approved,
Rejected
}

Compiles to:

javascript
123456
var Status;
(function (Status) {
Status[Status["Pending"] = 0] = "Pending";
Status[Status["Approved"] = 1] = "Approved";
Status[Status["Rejected"] = 2] = "Rejected";
})(Status || (Status = {}));

What just happened?

TypeScript created a bidirectional mapping object. You can look up both ways:

javascript
12
Status.Pending // 0
Status[0] // "Pending"

This reverse mapping is rarely useful but always increases your bundle size. For each enum value, you're shipping twice the code you need.

---

Problem #2: String Enums Are Only Slightly Better

"I'll just use string enums!" you might think:

typescript
12345
enum Status {
Pending = "PENDING",
Approved = "APPROVED",
Rejected = "REJECTED"
}

This compiles to:

javascript
123456
var Status;
(function (Status) {
Status["Pending"] = "PENDING";
Status["Approved"] = "APPROVED";
Status["Rejected"] = "REJECTED";
})(Status || (Status = {}));

Better — no reverse mapping. But you're still shipping a runtime object for something that could be purely a compile-time construct.

---

Problem #3: Enums Are Not Tree-Shakeable

Modern bundlers like Webpack, Rollup, and esbuild can "tree-shake" — removing unused code from your final bundle. Enums break this.

typescript
1234567891011121314
// config.ts
export enum Colors {
Red = "RED",
Green = "GREEN",
Blue = "BLUE",
Yellow = "YELLOW",
Orange = "ORANGE",
Purple = "PURPLE",
// ... 50 more colors
}
// app.ts
import { Colors } from "./config";
console.log(Colors.Red);

Even though you only use Colors.Red, the entire enum object ships to production. Bundlers can't safely remove individual enum members because they're all part of one runtime object.

---

Problem #4: Numeric Enums Are Dangerous

The default numeric enum behavior is a footgun:

typescript
12345678910111213
enum Status {
Pending, // 0
Approved, // 1
Rejected // 2
}
function processStatus(status: Status) {
console.log(status);
}
// This should be an error, but TypeScript allows it!
processStatus(42); // ✅ No error!
processStatus(999); // ✅ No error!

Wait, what? TypeScript lets you pass ANY number where an enum is expected. This defeats the entire purpose of type safety.

This happens because numeric enums are implemented as numbers at runtime, and TypeScript's type system doesn't prevent arbitrary number assignment.

---

Problem #5: Const Enums Have Their Own Issues

TypeScript offers const enum as a "fix":

typescript
1234567
const enum Status {
Pending,
Approved,
Rejected
}
const status = Status.Approved;

Compiles to:

javascript
1
const status = 1; // Inlined!

No runtime object — the values are inlined. Sounds great, but:

Problem 5a: Const Enums Break When Published

If you publish a library with const enum, consumers using --isolatedModules (required for Babel, esbuild, swc) will get errors. The enum values can't be inlined across package boundaries.

text
1
Cannot access ambient const enums when the '--isolatedModules' flag is provided.

This is why the TypeScript team recommends avoiding const enum in libraries.

Problem 5b: Const Enums Break Source Maps

Since values are inlined, debugging becomes harder. The debugger shows 1 instead of Status.Approved.

Problem 5c: Const Enums Require preserveConstEnums in Monorepos

In monorepos, you often need preserveConstEnums: true, which... generates the full enum anyway, defeating the purpose.

---

Problem #6: Enums Create a Type AND a Value

This is subtle but causes confusion:

typescript
12345678910111213
enum Status {
Pending,
Approved,
Rejected
}
// Status is both a TYPE and a VALUE
type StatusType = Status; // Using as type
const obj = Status; // Using as value
const pending = Status.Pending; // Accessing value
// This duality causes confusion in type-level code
type Keys = keyof typeof Status; // Need "typeof" - confusing!

The fact that enums exist in both the type space and value space creates cognitive overhead and edge cases that bite you in complex type manipulations.

---

Problem #7: Enums Don't Play Well With Object Types

You can't easily derive types from enums or use them in mapped types without awkward workarounds:

typescript
1234567891011121314
enum Status {
Pending = "PENDING",
Approved = "APPROVED",
Rejected = "REJECTED"
}
// Want a type with Status values as keys? It's ugly:
type StatusMap = {
[K in Status]: boolean;
};
// Type error! K is the VALUE ("PENDING"), not the key name
// This creates: { "PENDING": boolean, "APPROVED": boolean, ... }
// When you might want: { Pending: boolean, Approved: boolean, ... }

---

The Solution: Union Types + as const

Here's the modern approach that solves all these problems:

typescript
12345678
const Status = {
Pending: "PENDING",
Approved: "APPROVED",
Rejected: "REJECTED",
} as const;
type Status = (typeof Status)[keyof typeof Status];
// type Status = "PENDING" | "APPROVED" | "REJECTED"

Why This Is Better

1. Zero runtime overhead

The as const assertion is compile-time only. The object compiles to exactly what you wrote:

javascript
12345
const Status = {
Pending: "PENDING",
Approved: "APPROVED",
Rejected: "REJECTED",
};

2. Fully tree-shakeable

Bundlers can analyze object property access and remove unused values.

3. True type safety

typescript
1234567
function processStatus(status: Status) {
console.log(status);
}
processStatus("PENDING"); // ✅
processStatus("INVALID"); // ❌ Error: Argument of type '"INVALID"' is not assignable
processStatus(42); // ❌ Error: Argument of type '42' is not assignable

4. Works everywhere

No isolatedModules issues, no build configuration problems. It's just JavaScript objects and TypeScript types.

---

The Pattern: Making It Reusable

Create a helper type to reduce boilerplate:

typescript
123456789101112
// Helper type
type ValueOf<T> = T[keyof T];
// Define your constants
const Status = {
Pending: "PENDING",
Approved: "APPROVED",
Rejected: "REJECTED",
} as const;
// Derive the union type
type Status = ValueOf<typeof Status>;

Or use the built-in approach:

typescript
1234567
const Status = {
Pending: "PENDING",
Approved: "APPROVED",
Rejected: "REJECTED",
} as const;
type Status = (typeof Status)[keyof typeof Status];

---

Handling Numeric Constants

If you need numeric values (like HTTP status codes):

typescript
1234567891011121314151617181920
const HttpStatus = {
OK: 200,
Created: 201,
BadRequest: 400,
Unauthorized: 401,
NotFound: 404,
InternalServerError: 500,
} as const;
type HttpStatus = (typeof HttpStatus)[keyof typeof HttpStatus];
// type HttpStatus = 200 | 201 | 400 | 401 | 404 | 500
function handleResponse(status: HttpStatus) {
if (status === HttpStatus.OK) {
// TypeScript knows status is exactly 200 here
}
}
handleResponse(200); // ✅
handleResponse(999); // ❌ Error!

Unlike numeric enums, arbitrary numbers are rejected!

---

Getting Object Keys As a Type

Need the key names as a type? Easy:

typescript
1234567891011
const Status = {
Pending: "PENDING",
Approved: "APPROVED",
Rejected: "REJECTED",
} as const;
type StatusKey = keyof typeof Status;
// type StatusKey = "Pending" | "Approved" | "Rejected"
type StatusValue = (typeof Status)[keyof typeof Status];
// type StatusValue = "PENDING" | "APPROVED" | "REJECTED"

---

Real-World Example: API Response Types

typescript
1234567891011121314151617181920212223242526272829
const ApiErrorCode = {
InvalidInput: "INVALID_INPUT",
Unauthorized: "UNAUTHORIZED",
NotFound: "NOT_FOUND",
RateLimited: "RATE_LIMITED",
ServerError: "SERVER_ERROR",
} as const;
type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode];
interface ApiError {
code: ApiErrorCode;
message: string;
timestamp: number;
}
// Usage
const error: ApiError = {
code: ApiErrorCode.NotFound,
message: "User not found",
timestamp: Date.now(),
};
// Or use string directly (both work!)
const error2: ApiError = {
code: "NOT_FOUND",
message: "User not found",
timestamp: Date.now(),
};

---

What About Iteration?

Enums let you iterate over values. So do objects:

typescript
123456789101112131415161718
const Status = {
Pending: "PENDING",
Approved: "APPROVED",
Rejected: "REJECTED",
} as const;
// Get all values
const allStatuses = Object.values(Status);
// ["PENDING", "APPROVED", "REJECTED"]
// Get all keys
const allKeys = Object.keys(Status) as (keyof typeof Status)[];
// ["Pending", "Approved", "Rejected"]
// Iterate
for (const status of Object.values(Status)) {
console.log(status);
}

---

Migration Guide: Enum to Const Object

If you have existing enums, here's how to migrate:

Before (String Enum)

typescript
123456789
enum UserRole {
Admin = "ADMIN",
Editor = "EDITOR",
Viewer = "VIEWER",
}
function hasPermission(role: UserRole): boolean {
return role === UserRole.Admin;
}

After (Const Object)

typescript
1234567891011
const UserRole = {
Admin: "ADMIN",
Editor: "EDITOR",
Viewer: "VIEWER",
} as const;
type UserRole = (typeof UserRole)[keyof typeof UserRole];
function hasPermission(role: UserRole): boolean {
return role === UserRole.Admin;
}

The calling code doesn't need to change! Both UserRole.Admin and "ADMIN" work as values.

---

When ARE Enums Acceptable?

Honestly? Almost never. But if you insist:

  • String enums only — never numeric
  • Internal code only — never in published libraries
  • Small, stable sets — not 50+ values that might tree-shake
  • When reverse mapping is needed — rare but exists

Even then, I'd still recommend as const objects.

---

Summary

FeatureEnum`as const` Object
Runtime codeYes (IIFE)Minimal (plain object)
Tree-shakeable❌ No✅ Yes
Type safety (numbers)⚠️ Weak✅ Strong
Works with isolatedModules⚠️ Issues✅ Yes
Library-safe⚠️ Problematic✅ Yes
Debugging⚠️ Const enum issues✅ Clear
---

TL;DR

Stop using enums. Use as const objects instead.

typescript
12345678910111213
// ❌ Don't
enum Status {
Pending = "PENDING",
Approved = "APPROVED",
}
// ✅ Do
const Status = {
Pending: "PENDING",
Approved: "APPROVED",
} as const;
type Status = (typeof Status)[keyof typeof Status];

Your bundles will be smaller, your types will be safer, and your code will be more predictable.

---

Follow @codingwithshahar for more TypeScript tips!

#typescript#enums#best-practices#const-assertions

Stay Updated 📬

Get the latest tips and tutorials delivered to your inbox. No spam, unsubscribe anytime.