TypeScript Enums
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.
This article covers what an enum compiles to, the two flavors and their trade-offs, how enum members behave as types, 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',
})A TC39 proposal exists, but it is early-stage and has sat there for years. 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 — 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 deal with fixed sets of options: user roles, request states, days of the week. Without a named set you end up with magic strings sprinkled through the codebase, and typos slip through to runtime. An enum gives you a guardrail 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.
Constant vs computed members
TypeScript sorts members into two kinds. Constant members are the ones the compiler can evaluate at build time — a literal, or an expression built from literals and other members of the same enum. Computed members are everything else, evaluated when the code runs.
enum FileAccess {
None = 0,
Read = 1 << 1, // constant: the compiler folds this to 2
Write = 1 << 2, // 4
ReadWrite = Read | Write, // 6, still constant
Length = 'rw'.length, // computed: runs at runtime
}One rule comes attached. A member that follows a computed one needs its own initializer, because there is no value for the compiler to count up from:
enum Broken {
Length = 'rw'.length,
// Next, // ❌ TS1061: Enum member must have initializer
}The distinction matters more than it looks. A single computed member disqualifies the whole enum from the type behavior in the next section.
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.entries behave the way you expect. One more reason to prefer them.
Enum members are types
Every member of an enum is also a type in its own right. When every member has a literal value — no computed members anywhere — TypeScript treats the enum as a union enum, and narrowing works exactly like it does on a union of string literals:
enum Status {
Active = 'ACTIVE',
Archived = 'ARCHIVED',
Deleted = 'DELETED',
}
function describe(status: Status): string {
switch (status) {
case Status.Active:
return 'Live'
case Status.Archived:
return 'In the archive'
case Status.Deleted:
return 'Gone for good'
default:
// status is never here — add a fourth member and this breaks
const exhaustive: never = status
return exhaustive
}
}That exhaustiveness check is the main practical reason to keep an enum around. Add Status.Draft and the default branch stops compiling, so the compiler walks you to every switch that needs a new case.
You can also annotate with a single member, which is narrower than the enum itself:
enum Direction2 {
Up = 'UP',
Down = 'DOWN',
}
let onlyUp: Direction2.Up = Direction2.Up
// onlyUp = Direction2.Down // ❌ TS2322Getting the names and values as types
Two different unions come out of an enum, and mixing them up is a common source of confusion:
enum Level {
Debug = 'DEBUG',
Info = 'INFO',
}
type LevelName = keyof typeof Level
// "Debug" | "Info"
type LevelValue = `${Level}`
// "DEBUG" | "INFO"keyof typeof Level reads the keys off the enum object, so you get the member names. The template literal form `${Level}` goes the other way and gives you the string values — which is the type you want when a value arrives from JSON and you need to compare it without a cast.
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.SmallSmaller 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.
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.
Ambient enums
declare enum describes an enum that lives somewhere else, usually in a .d.ts file wrapping a JavaScript package:
declare enum Codec {
H264 = 'h264',
AV1 = 'av1',
}TypeScript trusts the declaration and emits nothing for it, which is fine on its own — member access compiles to an ordinary property lookup against whatever the runtime provides. Combine the two keywords, though, and declare const enum asks the compiler to inline values it has never seen defined. That is an error under isolatedModules (TS2748), the same toolchain problem one step further away.
Heterogeneous enums
TypeScript lets you mix string and numeric members in a single enum. It should not.
enum Mixed {
No = 0,
Yes = 'YES',
}This compiles. What you lose is consistency: Mixed.No gets a reverse mapping and Mixed.Yes does not, so Object.keys(Mixed) returns ["0", "No", "Yes"] — three entries for two members. The computed-value rules and the union-enum behavior both get murkier too. Keep an enum to one value type.
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 HeadingThe as const stops TypeScript widening each value to string, so the literal types survive. 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.
If you also want the object checked against a shape, reach for satisfies rather than a type annotation. satisfies Record<string, string> validates the values while leaving the literal types intact; an annotation would widen them straight back to string and collapse the union.
The payoff over an enum:
- It is plain JavaScript, so type stripping and
erasableSyntaxOnlyare fine with it. - Raw strings are assignable, so callers are not forced to import your object.
Object.keysandObject.valuesdo the obvious thing.- It works with mapped types the same way an enum does:
{ [K in Heading]: string }— or aRecord<Heading, string>— gives you an exhaustive lookup, and adding a fifth member turns every incomplete one into a compile error.
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 OrderState {
Active = 'ACTIVE',
Archived = 'ARCHIVED',
}
const isOrderState = (value: string): value is OrderState =>
Object.values(OrderState).includes(value as OrderState)
function parseOrderState(input: string) {
if (isOrderState(input)) {
return input // narrowed to OrderState
}
throw new Error(`Unknown state: ${input}`)
}With a union of string literals you write the same guard, but you skip the cast.
Common mistakes
- Assuming a numeric enum prevents invalid numbers. At runtime it is just a number.
- Relying on implicit numeric values. Reordering members changes them, silently breaking anything already persisted.
- Iterating a numeric enum without filtering out the reverse mapping.
- Adding one computed member and wondering why narrowing and
`${Enum}`stopped working. - Exporting a
const enumfrom a package. Consumers onisolatedModulescannot use it. - Expecting an enum to survive Node's type stripping. It will not.
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, and the exhaustive switch is genuinely good.
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.
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.
Become a TypeScript Pro
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges