Problem Solved

TypeScript · enum vs union

Home › Courses › TypeScript › Problems › enum vs union
🔷 Problem · 5 min read

Enum vs union.
The modern default, and when to break it.

Every TypeScript tutorial shows enums. Most modern code doesn't use them. What changed? Understanding the enum quirks — reverse mapping, runtime output, const enum pitfalls — makes the choice clear. This page explains both, shows the trade-offs, and gives you the decision rule for 2026.

Short answer: For string values, use union types — type Status = 'idle' | 'loading' | 'success' | 'error'. Zero runtime output, works with plain strings, no imports. Use enum only when you need reverse mapping (value → name) or the enum is genuinely a fixed numeric set used across many modules. In 2026, unions are the default. Enums are a legacy choice that still wins in specific cases.

✅ Solution 1 — Union types (the modern default)

String unions do everything enums do, with zero runtime cost.

A string literal union says "one of these exact strings." It's checked at compile time, erased at build time, and the values are just strings — so they work directly with JSON, APIs, and any external system without conversion. This is why modern code prefers them.

union.ts
// ─── The union ───────────────────────────────
type Status = 'idle' | 'loading' | 'success' | 'error';

const s: Status = 'loading'; // ✅
// const bad: Status = 'done'; // ❌ 'done' not in the union

// ─── Using it in a function ──────────────────
function label(s: Status): string {
switch (s) {
case 'idle': return 'Ready';
case 'loading': return 'Loading…';
case 'success': return 'Done';
case 'error': return 'Failed';
}
}

// ─── Wire-compatible with any API ────────────
// The value is literally 'loading' — the string.
// No conversion needed:
// fetch('/api/status', { body: JSON.stringify({ status: s }) });

// ─── Union + const object for labels ─────────
// If you need a runtime lookup, use satisfies:
const STATUS_LABELS = {
idle: 'Ready',
loading: 'Loading…',
success: 'Done',
error: 'Failed'
} satisfies Recordstring>;

function label2(s: Status): string {
return STATUS_LABELS[s];
}

// ─── Iterating the values ────────────────────
// Without an enum, iterate a const array:
const STATUSES = ['idle', 'loading', 'success', 'error'] as const;

// Zero runtime output for the type itself.
// The array above is the only runtime artifact,
// and only if you actually iterate.

console.log(label(s), label2('success'), STATUSES);
// Union type erased. Zero runtime output for the type.

const s = 'loading';

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

const STATUS_LABELS = {
idle: 'Ready',
loading: 'Loading…',
success: 'Done',
error: 'Failed'
};

function label2(s) {
return STATUS_LABELS[s];
}

const STATUSES = ['idle', 'loading', 'success', 'error'];

console.log(label(s), label2('success'), STATUSES);

Zero runtime output

type Status = 'idle' | ... compiles to nothing. No object, no code — just type checking. Enums produce a runtime object.

Wire-compatible

The value IS the string 'loading'. Pass it through JSON, APIs, URLs — no conversion, no enum.valueOf().

No import needed

Union types can be imported with import type — zero runtime import. Enums require a runtime import of the enum object.

Iterate with an array

Need to loop? A const arr = [...] as const gives you the values. Enums use Object.values(Enum) which includes reverse mappings for numeric enums.

Unions compose with utility types in ways enums can't. keyof typeof SomeObject, Pick, template literal types, and generic constraints all work on unions. Enums work too, but they're runtime values — using them in type positions requires typeof, keyof typeof, or extracting the value union. Unions are first-class citizens in the type system. That's the deeper reason modern code prefers them.

✅ Solution 2 — Enums (when they're actually right)

Runtime objects with reverse mapping. Useful — but rarer than you'd think.

An enum compiles to a runtime object with both forward (Status.Idle → 0) and reverse (Status[0] → 'Idle') mappings for numeric enums. String enums have only forward. These runtime capabilities are what enums give you that unions don't — and they're why enums still survive in certain codebases.

enum.ts
// ─── Numeric enum (has reverse mapping) ──────
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right // 3
}

const d = Direction.Up; // 0
const name = Direction[0]; // 'Up' — reverse mapping

// The compiled output is:
// {
// '0': 'Up', '1': 'Down', '2': 'Left', '3': 'Right',
// Up: 0, Down: 1, Left: 2, Right: 3
// }
// Both directions. This is the numeric enum's unique feature.

// ─── String enum (no reverse mapping) ────────
enum Status {
Idle = 'idle',
Loading = 'loading',
Success = 'success',
Error = 'error'
}

const s = Status.Loading; // 'loading'
// Status['loading'] // ❌ no reverse mapping for string enums

// ─── Union of enum values ────────────────────
// To get a union you have to extract it:
type StatusValue = Status; // 'idle' | 'loading' | 'success' | 'error'
// enum name used as a type gives the union of its values

// ─── Iterating enum values ───────────────────
// Numeric enums include reverse mappings in Object.values:
const allDirections = Object.values(Direction);
// ['Up', 'Down', 'Left', 'Right', '0', '1', '2', '3']
// — includes both the names and the numbers ⚠️

// String enums are cleaner:
const allStatuses = Object.values(Status);
// ['idle', 'loading', 'success', 'error'] — only values ✅

// ─── When enums are actually the right choice
//
// 1. Numeric flags used across many modules
// enum Permission { None=0, Read=1, Write=2, Admin=4 }
// const p = Permission.Read | Permission.Write // bitwise
//
// 2. Reverse mapping genuinely needed
// You want Direction[0] → 'Up' at runtime.
// (Rare, but real for protocol codes.)
//
// 3. Legacy codebase that already uses enums
// Don't migrate for the sake of it.
//
// 4. Library public API compatibility
// You're extending an existing enum-based library.

console.log(d, name, s, allStatuses);
// Enums DO produce runtime code (unlike union types).
// Numeric enum → bidirectional object:
// { '0': 'Up', '1': 'Down', ..., Up: 0, Down: 1, ... }

var Direction;
(function (Direction) {
Direction[Direction['Up'] = 0] = 'Up';
Direction[Direction['Down'] = 1] = 'Down';
Direction[Direction['Left'] = 2] = 'Left';
Direction[Direction['Right'] = 3] = 'Right';
})(Direction || (Direction = {}));

const d = Direction.Up;
const name = Direction[0];

// String enum → forward-only object:
var Status;
(function (Status) {
Status['Idle'] = 'idle';
Status['Loading'] = 'loading';
Status['Success'] = 'success';
Status['Error'] = 'error';
})(Status || (Status = {}));

const s = Status.Loading;

const allDirections = Object.values(Direction);
const allStatuses = Object.values(Status);

console.log(d, name, s, allStatuses);

Runtime object

Enums compile to JS objects. Numeric enums get reverse mapping; string enums don't. The object adds bytes to your bundle.

Reverse mapping (numeric only)

Direction[0] returns 'Up' — the name from the value. Only for numeric enums. String enums can't do this.

Iteration is inconsistent

Numeric enums include reverse mappings in Object.values — a common source of bugs. String enums iterate cleanly.

Bitwise flags

Numeric enums shine for flags: Permission.Read | Permission.Write. Unions can't express bitwise combinations as cleanly.

✅ Solution 3 — const enum and the modern reality

Why const enum is risky in 2026 — and what to use instead.

const enum was designed to eliminate the runtime object — references are inlined to their literal values at every use site. Great in theory. But it breaks with modern bundlers and isolatedModules, which Vite, esbuild, Babel, and Next.js all require. This is why almost no new codebase uses const enum.

const-enum.ts
// ─── const enum inlines values ───────────────
const enum Status {
Idle = 'idle',
Loading = 'loading',
Success = 'success'
}

const s = Status.Loading; // compiles to: const s = 'loading'
// No runtime object is created.
// The reference is inlined to the literal.

// ─── Why const enum is risky ─────────────────
//
// 1. Requires the compiler to see both the enum
// definition AND every usage site at once.
// Modern bundlers compile file-by-file (esbuild,
// Vite) — they can't inline across files.
//
// 2. Incompatible with isolatedModules: true,
// which is required by esbuild, Babel, and Vite.
//
// 3. tsconfig has preserveConstEnums to work
// around this, but then it's not really an
// optimization anymore.
//
// 4. ambient const enum (in .d.ts) is explicitly
// unsupported in some configurations.

// ─── Modern alternative: union + const object ─
type StatusValue = 'idle' | 'loading' | 'success';

const Status = {
Idle: 'idle',
Loading: 'loading',
Success: 'success'
} as const satisfies Record<string, StatusValue>;

// Usage:
// Status.Loading → 'loading' (literal type)
// StatusValue → 'idle' | 'loading' | 'success'
// Works in Vite, esbuild, Babel — no special config.
// Works with isolatedModules.
// Zero runtime gotchas.

// ─── The decision rule ───────────────────────
//
// Use union types → 90% of cases
// Use const object + satisfies → 9% of cases
// (need named "enum" access without enum quirks)
// Use enum → 1% of cases
// (numeric flags, reverse mapping, legacy)
// Use const enum → ~0%
// (breaks with modern bundlers)

console.log(s, Status.Loading);
// const enum: NO object emitted.
// Usage sites are inlined to literal values.

const s = 'loading'; // ← Status.Loading was inlined

// The modern alternative (const object) DOES produce a runtime object —
// but it's small, standard, and works everywhere:

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

console.log(s, Status.Loading);

const enum inlines

References become literal values. No runtime object — the smallest possible output. In theory, the ideal enum.

Breaks with bundlers

Requires isolatedModules: false, which esbuild, Vite, Babel, and Next.js require to be true. Incompatible in practice.

Modern alternative

type StatusValue = 'a' | 'b' | 'c' + const Status = {...} as const satisfies .... Works everywhere, no compiler config.

The rule

Unions for 90%. Const object for 9%. Regular enum for 1%. const enum for ~0%. Not a coincidence.

Don't use const enum in any new code. The performance argument ("no runtime object") is offset by the incompatibility with every modern bundler. If you need named constants without the enum runtime object, use a const object with as const satisfies. If you need reverse mapping, use a regular numeric enum. If you need neither, use a union type. There is no good case for const enum in 2026 codebases — the ecosystem decided against it.

⚠️ Common mistakes

The enum/union traps that cost real time.

1. Using enum for everything

Old habit from other languages. Fix: use union types for string values — 90% of cases. Enums only when you need runtime mapping.

2. Using const enum with Vite

Breaks with isolatedModules. Fix: use a const object + satisfies instead. Same API, no build issues.

3. Assuming enums are zero-cost

Enums produce runtime objects. For numeric enums, they're bidirectional. Fix: union types for zero-cost, or const enum (with caveats) if you need inlining.

4. Using Object.values(Enum) with numeric enums

You get ['Up', 'Down', '0', '1'] — names AND numbers. Fix: filter by type, or use string enums, or use a const array.

5. Iterating enum with for...in

for (const k in NumericEnum) iterates both directions. Fix: Object.values + filter, or use a string enum, or use a const array.

6. Type mismatch between enum and its values

enum Status { A = 'a' } — Status is an enum type, not string. Assigning 'a' to Status fails. Fix: use the enum, or use a union for string compat.

7. Mixing enum values and string literals

Status.Loading is an enum value; 'loading' is a string. They're not interchangeable without casts. Fix: pick one — union for strings, enum for enum-only.

8. Not knowing when to migrate

Enums in working code don't need migrating. Fix: migrate only when refactoring that area anyway. New code = new conventions.

❓ Frequently asked

Should I use enum or union in TypeScript?

For string values, prefer union types. Unions have zero runtime output, work with plain strings across API boundaries, don't require imports, and don't have enum's quirks. Use enum when you need reverse mapping or when the enum is genuinely a fixed numeric set used across many modules.

What is the difference between enum and union?

An enum is a runtime object — it compiles to a JS object with forward and reverse mappings. A union type is erased at compile time. Use enum when you need the runtime value. Use union when you only need type checking.

What is a const enum?

A const enum is a compile-time-only enum — references are inlined to their literal values at the use site. The trade-off: const enums are incompatible with isolatedModules (required by esbuild, Vite, Babel). In practice, if you're using Vite or esbuild, const enums are risky.

What is the enum reverse mapping quirk?

Numeric enums produce a reverse mapping: enum Status { Idle, Loading } produces { Idle: 0, Loading: 1, 0: 'Idle', 1: 'Loading' }. The bidirectional mapping lets you do Status[0] === 'Idle' at runtime. String enums don't have this — they only have the forward mapping.

🔗 Related

← All TypeScript problems
Problem solved · TypeScript