TypeScript Enums

August 28, 20269 min read
Requirements:
FunctionsObjects| Unions

If you came here looking for the JavaScript enum, here is the short version: there is no enum keyword in JavaScript. It is a TypeScript addition, and it is the one part of TypeScript that emits JavaScript you did not write — which is why it keeps showing up in "features to avoid" lists.

In this article we will cover why a JavaScript enum does not exist, what TypeScript's version compiles to, the two flavors and their trade-offs, why const enum is discouraged, why enums no longer run under Node's type stripping, and the as const pattern most teams reach for instead.

Is there a JavaScript enum?

No. JavaScript has no enum keyword and never has. enum is a reserved word in the ECMAScript spec — set aside for a feature that was never defined — so writing one in a .js file is a syntax error, not a missing polyfill:

// ❌ SyntaxError: Unexpected reserved word
// enum Size {
//   Small: 'SMALL',
//   Large: 'LARGE',
// }
 
// ✅ plain JavaScript: a frozen object
const Size = Object.freeze({
  Small: 'SMALL',
  Large: 'LARGE',
})

There is a TC39 proposal to add enums to the language, but it is early-stage and has sat there for years without advancing. Nothing is shipping in a browser or in Node, so treat the frozen object as the answer rather than something to wait out.

That leaves two paths. In TypeScript you get a real enum keyword, which is what the rest of this article covers. In plain JavaScript — or in TypeScript that has to survive Node's type stripping, since an enum generates code rather than annotating it — you use a frozen object and derive the type from it. That is the as const pattern, covered further down.

Why enums exist

In real projects you often deal with a fixed set of options. Think user roles, network request states, or days of the week. Without a named set you end up with magic strings sprinkled through the codebase, which leads to typos and invalid values slipping in at runtime.

Imagine a coffee shop with three cup sizes: Small, Medium, and Large. An order for Extra Large should be rejected, because that option does not exist. An enum gives you that guardrail in code, plus one place to look up the valid values.

Numeric vs string enums

TypeScript gives you two main flavors. Numeric enums auto-assign values starting at 0 unless you set one explicitly:

enum Direction {
  Up, // 0
  Down, // 1
  Left, // 2
  Right, // 3
}

These work when the values themselves do not matter, only that they are distinct. The catch is that the values are positional — insert a new member at the top and every number below it shifts. If those numbers are ever persisted to a database or sent over the wire, you have just corrupted your data.

String enums require you to assign a value to each member:

enum ResponseStatus {
  Success = 'SUCCESS',
  Failure = 'FAILURE',
  Pending = 'PENDING',
}

String enums are easier to debug because the value carries meaning without a lookup table, and reordering members is harmless. If you are going to use an enum at all, use a string enum.

What an enum compiles to

Unlike a type alias or an interface, an enum exists at runtime. TypeScript compiles it into a real JavaScript object, and that is the source of most enum surprises.

A numeric enum gets both a forward and a reverse mapping:

enum Compass {
  Up,
  Down,
}
 
// Forward: name to value
Compass.Up // 0
 
// Reverse: value back to name
Compass[0] // "Up"

That reverse mapping is why Object.keys on a numeric enum returns ["0", "1", "Up", "Down"] and not the two names you expected. Iterating a numeric enum without filtering is a classic bug:

enum Axis {
  Up,
  Down,
}
 
// ❌ four entries, not two
const all = Object.keys(Axis)
 
// ✅ only the names
const names = Object.keys(Axis).filter((key) => isNaN(Number(key)))

String enums have no reverse mapping. They compile to a plain object with just the key-value pairs, so Object.keys and Object.values behave the way you expect. One more reason to prefer them.

Const enums and why they are discouraged

TypeScript also has const enum. These do not exist at runtime at all — the compiler inlines the literal value at every use site:

const enum Size {
  Small = 'SMALL',
  Large = 'LARGE',
}
 
// compiles to: const chosen = "SMALL"
const chosen = Size.Small

Smaller output, no runtime object. The catch is that inlining requires the compiler to see the whole program at once, and modern toolchains do not work that way. Under isolatedModules — which Babel, esbuild, swc, and every bundler-based setup rely on — each file is transpiled in isolation, so a const enum imported from another module cannot be inlined. TypeScript flags ambient const enum access outright, and single-file transpilers either fail or silently produce a broken reference.

The practical rule: never export a const enum from a library, and do not reach for one unless you have measured a bundle-size problem it actually solves. A const object with as const gets the same ergonomics without the toolchain landmines.

Enums and erasable syntax

This is the change that moved enums from "debatable" to "avoid" for a lot of teams.

Node can now run TypeScript files directly by stripping the types out. Stripping is not compiling — it only erases annotations and replaces them with whitespace. That works for everything TypeScript adds on top of JavaScript except the handful of features that generate code: enums, parameter properties, namespaces with runtime values, and old-style decorators.

So an enum cannot be stripped. TypeScript ships a compiler flag for exactly this:

{
  "compilerOptions": {
    "erasableSyntaxOnly": true
  }
}

Turn it on and TypeScript reports an error on every enum declaration in your project, before Node ever gets the chance to. If you want your source files to be runnable by Node without a build step, enums are off the table. That is not a style opinion — it is a hard constraint.

The as const alternative

The pattern most codebases land on combines a frozen object with a union of string literals:

const Heading = {
  Up: 'UP',
  Down: 'DOWN',
  Left: 'LEFT',
  Right: 'RIGHT',
} as const
 
type Heading = (typeof Heading)[keyof typeof Heading]
// "UP" | "DOWN" | "LEFT" | "RIGHT"
 
function move(heading: Heading) {
  return heading
}
 
move(Heading.Up) // ✅
move('UP') // ✅ plain strings work too
// move('SIDEWAYS') // ❌ not assignable to type Heading

Two things are worth unpacking. The as const stops TypeScript widening each value to string, so the literal types survive — without it every property would be typed string and the union would collapse to string too. Then (typeof Heading)[keyof typeof Heading] reads those value types back out: the typeof operator turns the object into a type, keyof gets its keys, and the indexed access gives you the union of everything those keys point at.

Declaring the const and the type with the same name is deliberate. TypeScript keeps values and types in separate namespaces, so Heading works in both positions — exactly like an enum, minus the generated JavaScript. Autocomplete on Heading. is unchanged too, which is usually the thing people worry about losing.

The payoff over an enum:

If you want to practice building this shape from scratch, the Tuple to Enum Object challenge walks through deriving an enum-like object from a tuple at the type level.

Enums are nominal

Enum members are not interchangeable with the values they hold, and two structurally identical enums are not assignable to each other.

enum Fruit {
  Apple = 'APPLE',
}
 
enum Snack {
  Apple = 'APPLE',
}
 
let a: Fruit = Fruit.Apple
// a = Snack.Apple  // ❌ TS2322: Type 'Snack' is not assignable to type 'Fruit'
// a = 'APPLE'      // ❌ TS2322: Type '"APPLE"' is not assignable to type 'Fruit'

Both are TS2322, and the second one bites in practice. Anything crossing a boundary — a JSON response, a form value, a query param — arrives as a plain string, and TypeScript will not let you assign it to an enum-typed variable. Validate and narrow first:

enum Status {
  Active = 'ACTIVE',
  Archived = 'ARCHIVED',
}
 
const isStatus = (value: string): value is Status =>
  Object.values(Status).includes(value as Status)
 
function parseStatus(input: string) {
  if (isStatus(input)) {
    // input is narrowed to Status here
    return input
  }
  throw new Error(`Unknown status: ${input}`)
}

With a union of string literals you write the same guard, but you skip the cast.

Common mistakes

When to actually use one

Enums are not broken. On a full tsc build with no type-stripping requirement, a string enum is perfectly serviceable — especially in a codebase that already uses them everywhere. Consistency beats churn.

For new code, the default should be a union of string literals, reaching for the as const object when you also want a runtime value to iterate or map over. It slots straight into a discriminated union as the tag, which is where most fixed sets end up anyway. You get the same type safety, no generated JavaScript, and no toolchain caveats to remember.

Have you hit a tricky enum bug or found a pattern that works well for your team? We would love to hear how you handle fixed sets of values in TypeScript.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript enums knowledge to the test with these related challenges.

#472Tuple to Enum Object
Hard

Related Concepts

Concepts that build on or relate to typescript enums.

Union TypesInterfacesTypeScript typeofMapped TypesTypeScript RecordTypeScript Discriminated Unions

Common Errors

TypeScript errors you might encounter when working with typescript enums.

TS7029Fix TS7029: Fallthrough Case in SwitchTS7053Fix TS7053: Expression Can't Be Used to Index Type