Enums vs Union Types
When TypeScript enums help, when they hurt, and why string literal unions plus const objects win for most frontend code.
- 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
- Codegen from protobuf/OpenAPI that emits enums.
- Interop with a library that already exports enums.
- 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
- Numeric enums without explicit values — reorder breaks storage.
- Heterogeneous enums — rarely justified.
- Union of string and bare
string— collapses the union. - Using
enumas 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.”
Related on this site
- Literal types
- Discriminated unions for UI state
- Readonly and const assertions
- never type and exhaustiveness
- keyof typeof and indexed access
Further reading
Related guides
- Basic Types and AnnotationsPrimitives, arrays, objects, and function annotations TypeScript actually checks — plus where inference is enough and where it is not.
- Branded Types for IDsNominal-style UserId vs OrderId in TypeScript — prevent ID mixups at compile time with brands, parsers, and form boundaries.
- Conditional Types IntroT extends U ? X : Y — how TypeScript picks types from conditions, distributes over unions, and powers utility types you already use.
- Declaration Files and DefinitelyTypedHow .d.ts files describe JS to TypeScript, when to use @types packages, module augmentation, and writing minimal ambient types for untyped libs.
- Discriminated Unions for UI StateModel loading, success, and error as mutually exclusive variants so TypeScript and your UI cannot show impossible states.