Blog
3 min read

TypeScript Enums vs Union Types: Which Should You Use?

TypeScript enums generate runtime code and have surprising behaviour; string literal unions and as const objects give the same safety with plain JavaScript. How each works, why erasableSyntaxOnly and Node's built-in type stripping reject enums, and a practical rule for choosing.

Say an order can be pending, paid or shipped. TypeScript gives you three ways to model that, and the community has largely settled on which to prefer.

Option 1: an enum

enum OrderStatus {
  Pending = 'pending',
  Paid = 'paid',
  Shipped = 'shipped',
}

function ship(status: OrderStatus) { /* ... */ }
ship(OrderStatus.Paid)
ship('paid')   // ❌ Error — a plain string isn't the enum, even with the same value

Option 2: a string literal union

type OrderStatus = 'pending' | 'paid' | 'shipped'

function ship(status: OrderStatus) { /* ... */ }
ship('paid')     // ✅
ship('payed')    // ❌ Error, with autocomplete suggesting the right values

Option 3: an as const object plus a derived union

When you also need the values at runtime — for a dropdown, a loop, validation:

export const ORDER_STATUS = {
  Pending: 'pending',
  Paid: 'paid',
  Shipped: 'shipped',
} as const

export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS]
// 'pending' | 'paid' | 'shipped'

Object.values(ORDER_STATUS)   // ['pending', 'paid', 'shipped']

This gives you ORDER_STATUS.Paid like an enum, but it's an ordinary JavaScript object and the type is a plain union.

Why many teams avoid enums

1. They aren't "just types". Most TypeScript disappears when compiled. Enums generate real JavaScript objects, so they behave differently from everything else in the type system.

2. They don't work with type stripping. Node.js can now run .ts files directly by stripping type annotations — but enums need code generation, so they fail unless you enable a transform flag. TypeScript's erasableSyntaxOnly option (5.8+) flags enums, namespaces and parameter properties for the same reason. Bundlers that strip types per file have similar limits. (JavaScript vs TypeScript)

3. Numeric enums are loose. With numeric enums (enum Size { Small, Large }), TypeScript has historically let any number through, and reverse mappings add confusing keys:

enum Size { Small, Large }
Object.keys(Size)   // ['0', '1', 'Small', 'Large']

4. Strings from outside don't fit. Data from an API or database arrives as 'paid', which isn't assignable to OrderStatus.Paid without a cast. Unions accept it once validated. (Validate Input With Zod)

5. const enum has its own pitfalls with isolated module compilation used by most modern tooling.

When enums are still fine

  • Existing codebases that use them consistently — don't churn working code.
  • Teams that like the namespaced OrderStatus.Paid style and compile with tsc.

If you do use them, prefer string enums over numeric ones.

The practical rule

  • Just need a type? String literal union.
  • Need the values at runtime too? as const object + derived union.
  • Validating outside data? Define the values once (e.g. z.enum(['pending', 'paid', 'shipped'])) and infer the type from it.

Databases have the same question — Postgres enum types vs CHECK constraints vs lookup tables — and the same answer tends to apply: choose the option that's easiest to change later.

The summary

  • Enums generate runtime code and don't work with type-stripping tools.
  • Unions give the same safety with zero runtime cost; as const objects add runtime values.
  • Prefer unions for new code; keep existing enums if they work for you.

EasySpawn gives Claude Code a full workspace with your TypeScript toolchain, so a refactor from enums to unions runs through tsc and your tests before you see it. See how it works or join the waitlist.

Related: TypeScript satisfies Explained · type vs interface · TypeScript Generics · Property Does Not Exist on Type

Keep reading