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.Paidstyle and compile withtsc.
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 constobject + 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 constobjects 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
type vs interface in TypeScript: Which Should You Use?
type aliases and interfaces both describe object shapes, and mostly behave the same. The real differences — unions and other non-object types, declaration merging, extends vs intersections, error messages — and a simple, consistent rule for choosing in your codebase.
TypeScript satisfies Explained: Check a Type Without Losing Precision
The satisfies operator checks that a value matches a type while keeping the value's own, more specific type. How it differs from a type annotation and from as, the patterns where it shines (config objects, route maps, as const), and the mistakes to avoid.