ESC

Type to search the knowledge base.

Enums vs Union Types

When TypeScript enums help, when they hurt, and why string literal unions plus const objects win for most frontend code.

intermediate4 min read
  • typescript
  • enums-vs

You need a closed set of values: statuses, roles, button variants. TypeScript offers enums and string/number literal unions. For modern frontend codebases, unions (often with a as const object) are usually the better default. Enums still appear in older code and some API codegen — know both.

Docs: Enums, Literal types.

String literal unions

type Status = 'idle' | 'loading' | 'success' | 'error';

function setStatus(s: Status) {
  /* … */
}

setStatus('loading'); // ok
// setStatus('done'); // error

Zero runtime cost. Serializes cleanly to JSON. Easy to exhaust in switch.

as const object as a value map

const Status = {
  Idle: 'idle',
  Loading: 'loading',
  Success: 'success',
  Error: 'error',
} as const;

type Status = (typeof Status)[keyof typeof Status];
// 'idle' | 'loading' | 'success' | 'error'

function isStatus(v: string): v is Status {
  return (Object.values(Status) as string[]).includes(v);
}

You get runtime values and a type derived from them — no enum emit quirks.

TypeScript enums

enum Direction {
  Up = 'UP',
  Down = 'DOWN',
}

String enums are predictable. Numeric enums are where people get burned:

enum Role {
  User,  // 0
  Admin, // 1
}

// Reverse mapping exists for numeric enums
Role[0]; // 'User'

Numeric enums are real objects at runtime, can be reverse-mapped, and accept surprising assignments historically (Role.User compatible with other numbers in some patterns). Prefer explicit string values if you must use enums.

enum Role {
  User = 'user',
  Admin = 'admin',
}

const enum

const enum Feature {
  DarkMode = 'dark',
}
// inlines members — breaks if you need runtime Object.values

const enum + isolatedModules / bundlers can conflict. Many style guides ban them.

Comparison table

Concern String union + as const TS enum
Runtime cost None (or plain object you own) Emits JS object (except const enum)
JSON / API Natural strings Must match string enum values carefully
Tree-shaking Excellent Can be awkward
Iterate values Object.values(map) Possible but clunky
Nominal flavor Structural Slightly more nominal feel
Ecosystem Preferred in modern TS Common in Java-style ports

Frontend realities

Design system variants:

type ButtonVariant = 'primary' | 'secondary' | 'danger';

API contracts: backends send strings. Modeling them as unions matches the wire format. Enums force translation layers.

React props:

type TabsProps = {
  value: 'overview' | 'activity' | 'settings';
  onChange: (v: TabsProps['value']) => void;
};

When enums are fine

  1. Codegen from protobuf/OpenAPI that emits enums.
  2. Interop with a library that already exports enums.
  3. Team standard already enum-heavy — consistency beats purity.

Don’t convert a working enum codebase mid-feature for ideology. Prefer unions for new closed sets.

Exhaustiveness still works

type Status = 'idle' | 'loading' | 'success' | 'error';

function label(s: Status): string {
  switch (s) {
    case 'idle':
      return 'Idle';
    case 'loading':
      return '…';
    case 'success':
      return 'Done';
    case 'error':
      return 'Failed';
  }
}

Same pattern with string enums; unions avoid the extra runtime type.

Footguns

  1. Numeric enums without explicit values — reorder breaks storage.
  2. Heterogeneous enums — rarely justified.
  3. Union of string and bare string — collapses the union.
  4. Using enum as a namespace for unrelated constants — use objects.
// Collapses to string
type Bad = 'a' | 'b' | string;

Interview out-loud answer

“I default to string literal unions, often derived from an as const object so I have runtime values. Enums emit runtime objects and numeric enums have reverse-mapping quirks. I use enums when codegen or an existing API already does.”

Further reading

Related guides