TypeScript by Example

Enums

Numeric, string, and const enums; heterogeneous enums; reverse mapping; and when to use string literal unions instead.

Enums define a named set of constants. TypeScript's enum is a compile-time and runtime construct - it emits a JavaScript object. Knowing what gets emitted is the key to avoiding the footguns.

Numeric enums auto-increment from 0 by default. TypeScript generates a reverse mapping - Direction[0] returns "Up". The emitted object contains both Direction.Up = 0 and Direction[0] = "Up".

enum Direction {
  Up,    // 0
  Down,  // 1
  Left,  // 2
  Right, // 3
}
 
console.log(Direction.Up);        // 0
console.log(Direction[0]);        // "Up"  ← reverse mapping
console.log(Direction["Down"]);   // 1
 
function move(dir: Direction): string {
  if (dir === Direction.Up) return "moving up";
  return `moving to ${Direction[dir]}`;
}
 
console.log(move(Direction.Left)); // "moving to Left"

String enums assign string values explicitly. There is no reverse mapping. String enums are safer than numeric ones: the serialized value is human-readable and there's no falsy-zero footgun.

enum Status {
  Pending  = "PENDING",
  Active   = "ACTIVE",
  Inactive = "INACTIVE",
  Deleted  = "DELETED",
}
 
console.log(Status.Active);  // "ACTIVE"
console.log(Status["Active"]); // "ACTIVE"
// No reverse mapping: Status["ACTIVE"] → undefined
 
function printStatus(s: Status): void {
  console.log(`Status is: ${s}`);
}
 
printStatus(Status.Pending); // "Status is: PENDING"

const enum inlines values at every use site and emits no runtime object. This produces smaller output but breaks when the enum is consumed from a different compilation unit or with isolatedModules: true.

const enum Axis { X = "x", Y = "y", Z = "z" }
 
function rotate(axis: Axis, degrees: number): string {
  return `rotate ${degrees}° on ${axis}`;
}
 
// Emitted JS: rotate("x", 90) - the string is inlined, no Axis object exists
console.log(rotate(Axis.X, 90)); // "rotate 90° on x"
 
// Regular enum for comparison - emits a full JS object
enum Color { Red = "#E63946", Blue = "#3178C6" }
console.log(Color.Red); // "#E63946"

String literal unions achieve the same type safety as string enums with less ceremony - no emit, no import, natural JSON serialization.

// Enum version - emits JS, requires import, can't spread into JSON cleanly
enum RoleEnum { Admin = "admin", Member = "member", Guest = "guest" }
 
// Union version - type only, no emit, identical runtime behavior
type Role = "admin" | "member" | "guest";
 
function greet(role: Role): string {
  return role === "admin" ? "Welcome, admin." : `Hello, ${role}.`;
}
 
// The union works directly with JSON without translation
const payload = { userId: "u-1", role: "admin" as Role };
console.log(JSON.stringify(payload)); // {"userId":"u-1","role":"admin"}

In production

Numeric enums are the most dangerous TypeScript feature in production APIs. 0 is falsy, so if (status) silently skips the first enum member - a common source of bugs when the default state maps to 0. String enums are safer but still emit a runtime object and require an import wherever they're used, which creates coupling across module boundaries. The practical recommendation for most codebases: use string literal unions (type Role = "admin" | "member") for variant types that appear in API contracts; use const enum only in single-package TypeScript projects that don't use isolatedModules; avoid plain numeric enums in any code that touches external data. If you inherit a codebase full of enums, @typescript-eslint/prefer-literal-type can flag them automatically.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free