Enums — and Their Sharp Edges

Intermediate · 20 min read · ▶ live playground · ✦ checkpoint

Enums are TypeScript's built-in way to name a fixed set of constants, but they are not just types. Numeric enums, string enums, and const enum all have runtime consequences, and this lesson is the one place in the course where we study those consequences directly.

Numeric and string enums

A numeric enum starts at 0 unless you say otherwise, then auto-increments. A string enum requires each member to have a string value. Both give you named members at the type level and an object at runtime:

enum Direction {
  Up,
  Down,
  Left = 10,
  Right,
}
 
enum ApiState {
  Loading = "loading",
  Success = "success",
  Error = "error",
}
 
console.log(Direction.Up);
console.log(Direction[10]);
console.log(Direction.Right);
console.log(ApiState.Success);

→ 0
→ Left
→ 11
→ success

Here is the exact JavaScript this course checker emits:

var Direction;
(function (Direction) {
    Direction[Direction["Up"] = 0] = "Up";
    Direction[Direction["Down"] = 1] = "Down";
    Direction[Direction["Left"] = 10] = "Left";
    Direction[Direction["Right"] = 11] = "Right";
})(Direction || (Direction = {}));
var ApiState;
(function (ApiState) {
    ApiState["Loading"] = "loading";
    ApiState["Success"] = "success";
    ApiState["Error"] = "error";
})(ApiState || (ApiState = {}));
console.log(Direction.Up);
console.log(Direction[10]);
console.log(Direction.Right);
console.log(ApiState.Success);

The numeric enum emits a two-way object. Direction.Up reads 0, and Direction[0] reads "Up". That second direction is called reverse mapping. The string enum emits only the forward names: ApiState.Success reads "success", but there is no generated reverse lookup from "success" back to "Success".

The numeric enum trap

Reverse mapping looks convenient until a numeric enum receives a number from outside the enum's own members. TypeScript 5.9 rejects obvious bad literals like const door: Door = 99, but a value already typed with broad number can still flow into a numeric enum position:

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ unknown door: 99
→ undefined

This is the main reason modern application code often uses literal unions for closed choices. "open" | "closed" is a pure type-level set; it emits no object, has no reverse map, and does not accept a broad string without a check. Section 8.4 will turn that into a decision table, but the warning starts here.

const enum

A const enum asks the compiler to avoid the runtime object and inline member values where possible. The idea is simple: if Permission.Read is always 1, the generated JavaScript can use 1 directly.

But that promise is tool-shaped. This site's playground and checker use an isolated one-file transpiler, so the verified emit below deliberately preserves a runtime object instead of showing whole-project inlining:

const enum Permission {
  Read = 1,
  Write = 2,
  Execute = 4,
}
 
const editor = Permission.Read + Permission.Write;
console.log(editor);

→ 3

var Permission;
(function (Permission) {
    Permission[Permission["Read"] = 1] = "Read";
    Permission[Permission["Write"] = 2] = "Write";
    Permission[Permission["Execute"] = 4] = "Execute";
})(Permission || (Permission = {}));
const editor = Permission.Read + Permission.Write;
console.log(editor);

In a normal whole-program tsc build, const enum can be inlined because the compiler can see the enum declaration and every use together. In isolated transforms, each file is compiled without that whole-program knowledge, so tools cannot safely replace every enum reference with a value from another file.

That becomes painful when libraries publish ambient const enum declarations in .d.ts files. A consumer using isolatedModules can see the type declaration, but the one-file compiler cannot safely inline it:

error TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.

Enums are not banned. They are just not the lightest default anymore. Use this lesson's checklist: do you need a runtime object, interop with existing enum-based APIs, or a deliberate numeric bit flag? An enum may earn its place. If you only need a closed set of choices for the checker, literal unions and modern constant patterns usually fit better. Next lesson builds the most common pattern: preserving precise constants without creating an enum object.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion