TypeScript enums compile to runtime objects (numeric enums auto-increment, string enums require explicit values), while union types are erased at compile time with zero overhead — most teams prefer string unions for string constants and reserve enums for cases needing runtime object access.
Enums compile to JavaScript objects that exist at runtime. Union types are erased during compilation with zero bundle size impact.
Auto-increment from 0 with bidirectional mapping (forward and reverse). The reverse mapping can cause unexpected iteration behavior.
Most teams use string literal unions for string constants — same DX (autocomplete, type safety) with no runtime cost and simpler code.
const STATUSES = ['active', 'inactive'] as const with typeof gives both a runtime array for iteration and a compile-time union type.
Enums define a set of named constants. TypeScript has three enum variants, each with different compilation behavior and tradeoffs.
Numeric enums auto-increment from 0 (or from an explicit starting value):
enum Direction { Up, Down, Left, Right }
// Up = 0, Down = 1, Left = 2, Right = 3Numeric enums compile to a JavaScript object with both forward mapping (Direction.Up === 0) and reverse mapping (Direction[0] === 'Up'). This bidirectional mapping is useful when you need to display enum names for numeric values (e.g., logging, debugging).
The reverse mapping is a quirk that many developers don't expect: Object.keys(Direction) returns ['0', '1', '2', '3', 'Up', 'Down', 'Left', 'Right'] — both the number keys and string keys. This can cause bugs when iterating.
String enums require explicit values for every member:
enum Status { Active = 'ACTIVE', Inactive = 'INACTIVE', Pending = 'PENDING' }String enums compile to a forward-only mapping object — no reverse mapping. The values are readable in debugging output and JSON payloads. They're more predictable than numeric enums.
const enum declarations are inlined at compile time — the enum object is never emitted:
const enum Color { Red, Green, Blue }
let c = Color.Red; // compiles to: let c = 0;This eliminates the runtime object entirely, reducing bundle size. However, const enum has limitations: no reverse mapping, incompatible with isolatedModules (used by Babel, esbuild, SWC), and problematic across package boundaries. Many projects avoid const enum because of these issues — the TypeScript team has recommended against them in libraries.
String literal unions provide the same developer experience with zero runtime overhead:
type Status = 'active' | 'inactive' | 'pending';Unions are erased at compile time — no runtime object, no bundle size cost. They provide autocomplete, type safety, and exhaustive switch checking (with never in the default case). The tradeoff: no runtime access to the list of valid values, and no built-in iteration.
To get the "list of values" that enums provide, use as const arrays:
const STATUSES = ['active', 'inactive', 'pending'] as const;
type Status = (typeof STATUSES)[number]; // 'active' | 'inactive' | 'pending'This gives you both a runtime array for iteration/validation and a compile-time union type.
as const arrays: Use when you need both a runtime array of values and a compile-time union type.Enums exist at runtime as JavaScript objects — they add code to your bundle. Union types are erased at compile time with zero overhead. String unions are preferred for most use cases. Use as const arrays when you need both runtime iteration and type safety. Avoid const enum in libraries and projects using transpilers like esbuild or Babel.
Fun Fact
Enums are one of the few TypeScript features that are NOT just type-level — they generate runtime JavaScript code. The TypeScript team has said that if they were designing TypeScript today, they probably wouldn't add enums at all. The `as const` + union pattern didn't exist when enums were designed, and it covers most use cases with less complexity.