TypeScript

Enums vs union literals

TypeScript ships a dedicated enum keyword for "one of a fixed set of named values" — and much of the modern TypeScript ecosystem quietly prefers the plain union-literal pattern from the previous lesson instead. Knowing why is more useful than memorizing enum syntax.

Intermediate

4 min read

The enum keyword, doing the obvious thing

enum Status {
  Pending,
  Confirmed,
  Cancelled,
}
 
let s: Status = Status.Pending;
console.log(Status.Pending); // 0 — numeric enums auto-assign 0, 1, 2, ...

enum declares a fixed, named set of values directly as a language construct — Status.Pending, Status.Confirmed, Status.Cancelled are the three valid members, and a variable typed Status can only hold one of them. Without explicit values, a numeric enum auto-assigns 0, 1, 2, ... in declaration order, which is convenient but has a real cost covered below.

Numeric enums are NOT type-safe the way they look

enum Status {
  Pending,
  Confirmed,
}
 
function process(s: Status) { }
process(0);   // fine! — any number matching a valid enum position is accepted
process(99);  // Error — but only because 99 doesn't match ANY member's number

This is the single biggest, most-cited problem with numeric enums: because each member is really just a number underneath, TypeScript accepts any number that happens to match a valid enum position as if it were that enum value — process(0) type-checks fine without ever writing Status.Pending at all, silently accepting a bare, unrelated magic number. This defeats a real part of what an enum should be providing: a closed, self-documenting set of valid values that can't be confused with an arbitrary number from somewhere else in the code.

const enum: the compile-time-only variant, and its own operational trap

const enum Direction {
  Up,
  Down,
}
 
let d = Direction.Up; // compiles to the literal number 0 directly — Direction itself is erased

A const enum is fully inlined at every usage site during compilation — no Direction object exists at all in the compiled output, Direction.Up is replaced with the literal 0 wherever it's used. This is a genuine performance/bundle-size win, but it comes with a real, documented gotcha: const enums cannot be used across certain module boundaries (isolated module compilation, common in tools like esbuild and SWC used by many modern bundlers) without extra configuration, since inlining requires the compiler to see the enum's full declaration at every call site — this is a large part of why many teams avoid const enum in library code specifically.

Why the union-literal pattern is now the widely preferred default

// The plain union-literal pattern, no enum keyword at all
type Status = "pending" | "confirmed" | "cancelled";
 
function process(s: Status) { }
process("pending");   // fine
process("PENDING");    // Error — literal types are exact, case-sensitive matches
process("anything");    // Error — not a member of the union at all

A union of string literal types gets the same "closed set of valid values" guarantee an enum promises, but without numeric enums' silent-any-matching-number weakness — every value in the union has to be one of the exact declared strings, with no equivalent backdoor. It also has zero runtime footprint (it's erased completely, like every other TypeScript type, per the very first lesson), needs no separate import { Status } the way a real enum object does, and reads naturally as plain, debuggable string values in logs and browser dev tools instead of an opaque number.

Object literal as const, when you actually want named constants at runtime

const Status = {
  Pending: "pending",
  Confirmed: "confirmed",
  Cancelled: "cancelled",
} as const;
 
type Status = typeof Status[keyof typeof Status]; // "pending" | "confirmed" | "cancelled"
 
function process(s: Status) { }
process(Status.Pending); // "Status.Pending" style access, like a real enum

This pattern (sometimes informally called a "const object") gets the best of both worlds when Status.Pending-style dotted access is genuinely wanted: real runtime values usable in actual JavaScript logic (unlike a bare union type, which is purely compile-time), combined with the exact-match strictness of literal types — no silent numeric backdoor, and the values are readable strings in logs. This is close to the modern, idiomatic replacement for enum in most new TypeScript code, reserving the real enum keyword for cases where its specific extra features (like automatic reverse mapping for numeric enums) are genuinely needed.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the real weakness of TypeScript's numeric enums, as demonstrated by `process(0)` type-checking even without writing `Status.Pending`?

2. Why do many modern TypeScript codebases prefer plain string-literal unions (e.g. `type Status = "pending" | "confirmed"`) over the `enum` keyword?

3. What is a documented operational trap specific to `const enum`?