Stop reaching for enums by default in TypeScript
July 2, 2026 · 5 min read

Enums are usually the first thing TypeScript developers reach for when they need a fixed set of values. They look purpose-built for it — there's literally an enum keyword. But enums are also one of the few TypeScript features that generate real JavaScript at runtime and carry behavior that surprises people. Once you've been bitten a few times, a plain object with as const starts looking like the better default.
Here's the pattern I now reach for instead:
const Direction = {
Up: "UP",
Down: "DOWN",
} as const;
// keys: "Up" | "Down"
type DirectionKeys = keyof typeof Direction;
// values: "UP" | "DOWN"
type DirectionValue = typeof Direction[keyof typeof Direction];
That's just an object and two type helpers — no new language feature. Before I show why it's nicer, let me make the actual case against enums, because "I prefer objects" isn't an argument. Here's where enums genuinely bite.
Gotcha 1: numeric enums aren't type-safe (the big one)
This is the one that surprises people. A numeric enum accepts any number, not just its own members:
enum Direction { Up, Down } // Up = 0, Down = 1
function move(d: Direction) { /* ... */ }
move(Direction.Up); // fine
move(5); // ✅ no error — 5 is not a valid Direction, but TS allows it
The thing you adopted the enum for — restricting to a known set — silently doesn't hold for numeric enums. A union (or the const-object value type) rejects 5 outright. So the feature that looks safest is the least safe.
Gotcha 2: enums emit runtime code (and it's weird)
Types are supposed to vanish at compile time. Enums don't — they compile to a real object, and numeric enums compile to a reverse-mapped object that maps both ways:
enum Direction { Up, Down }
// compiles to roughly:
// { 0: "Up", 1: "Down", Up: 0, Down: 1 }
Direction[0] gives "Up" and Direction.Up gives 0 — a two-way map you didn't ask for, shipped in your bundle. It's rarely what you want, it bloats output slightly, and it means Object.keys(Direction) returns twice the entries you'd expect. A const object is exactly the shape you wrote, nothing more.
Gotcha 3: enums are nominal, which bites at boundaries
Enum members are their own type, so a plain string that equals the value isn't assignable to the enum:
enum Status { Active = "ACTIVE" }
const s: Status = "ACTIVE"; // ❌ Type '"ACTIVE"' is not assignable to type 'Status'
This shows up constantly at data boundaries — an API returns the string "ACTIVE", and you can't just assign it to your enum type without a cast. With a const-object union, the value is the string "ACTIVE", so it flows in naturally.
Gotcha 4: extracting keys and values has rough edges
With a const object, Object.keys and Object.values give you exactly what you wrote — no surprises:
const Direction = {
Up: 0,
Down: 1,
} as const;
Object.keys(Direction) // ["Up", "Down"]
Object.values(Direction) // [0, 1]
Extracting those as TypeScript types is the two-liner from the top:
type DirectionKey = keyof typeof Direction; // "Up" | "Down"
type DirectionValue = typeof Direction[keyof typeof Direction]; // 0 | 1
With a numeric enum, Object.keys silently returns twice as many entries because of the reverse map:
enum Direction { Up, Down }
Object.keys(Direction) // ["0", "1", "Up", "Down"] ← doubled
Object.values(Direction) // [0, 1, "Up", "Down"] ← mixed types
String enums avoid the reverse map so runtime keys and values behave normally, but there's no built-in type helper for the value union. The common workaround is a template literal type:
enum Status { Active = "ACTIVE", Inactive = "INACTIVE" }
type StatusValue = `${Status}` // "ACTIVE" | "INACTIVE"
It works, but it's less obvious than the const-object equivalent and doesn't exist for numeric enums at all.
Why the const object avoids all of it
Walk back through the list with the as const pattern:
It's fully type-safe — DirectionValue is exactly "UP" | "DOWN", so nothing else is assignable. It emits only the object you wrote — no reverse map, no surprise runtime shape. It's structural, not nominal — the value is a plain string, so data from APIs and other packages flows in without casts.
You also get both the keys and the values as types for free, using the two helpers from the top:
type DirectionKeys = keyof typeof Direction; // "Up" | "Down"
type DirectionValue = typeof Direction[keyof typeof Direction]; // "UP" | "DOWN"
The typeof Direction[keyof typeof Direction] line reads like a riddle the first time, so here's the decode: typeof Direction is the object's type, keyof typeof Direction is its keys ("Up" | "Down"), and indexing the object type by all its keys gives you the union of its values ("UP" | "DOWN"). It's the standard idiom for "give me the value union of a const object," and it's worth committing to memory.
The honest caveat
This isn't "enums are evil." String enums are mostly fine, and if a codebase already uses them consistently, ripping them out isn't worth it. The claim is narrower and, I think, correct: as a default for a fixed set of values, a const object with as const is safer (no numeric-enum hole), lighter (no surprise runtime code), and friendlier at boundaries (structural, not nominal) — with the only cost being one slightly cryptic type helper you learn once. Reach for it first; reach for an enum only when you have a specific reason to.
Which do you default to — enums out of habit, or have you already switched to as const objects?