Two ways to express a fixed set of allowed values, with different trade-offs
TypeScript offers two fundamentally different tools for representing a fixed set of allowed values: enums and union literal types. Both have legitimate use cases, but the choice has real consequences for bundle size, serialization, and interoperability.
Table of Contents
- 1. Two Approaches to a Fixed Set of Values
- 2. Numeric Enums and Their Quirks
- 3. String Enums and const enum
- 4. Union Literal Types as an Alternative
- 5. Structural vs. Nominal Behavior
- 6. JSON Serialization and API Boundaries
- 7. When Enums Are Actually the Right Choice
- 8. Practical Recommendation
- 9. Migration Pitfalls
- 10. Summary
- 11. FAQ
1. Two Approaches to a Fixed Set of Values
Both enums and union literal types solve the same underlying problem: a variable should only ever hold one of several predefined values. How that goal is reached, however, differs fundamentally. Enums create an actual runtime construct, while union literal types exist purely within the type system.
This difference in nature has consequences that go well beyond taste: bundle size, JSON serialization behavior, debugging experience, and interoperability with plain JavaScript code are all affected.
Teams coming from languages with classic enums are often tempted to reach for TypeScript enums out of habit. A deliberate comparison of both approaches pays off before a convention becomes locked in across an entire project.
2. Numeric Enums and Their Quirks
A numeric enum creates a runtime object with a so-called reverse mapping property: not only does the name point to the value, the value also points back to the name. That's handy for debugging output, but it doubles the number of entries in the generated JavaScript object.
Another risk of numeric enums is their implicit compatibility with any number. TypeScript allows any number variable to be passed where a numeric enum is expected, without the compiler flagging it.
enum Status {
Draft,
Published,
Archived,
}
console.log(Status.Published); // 1
console.log(Status[1]); // "Published" (reverse mapping)
function setStatus(s: Status) {}
setStatus(42); // not flagged by the compiler
3. String Enums and const enum
String enums avoid the reverse mapping issue and the implicit compatibility with arbitrary numbers, since every member gets an explicit string value. They're considerably safer than numeric enums, but they still create a real runtime object.
A const enum, by contrast, is fully resolved at compile time and leaves no runtime object behind at all: every usage is replaced directly by its literal value. This saves on bundle size, but it doesn't work with the isolatedModules compiler option required by tools like esbuild, swc, or Babel, since those transpile files in isolation without being able to know an enum's value.
enum ExportFormat {
Json = "json",
Xml = "xml",
Csv = "csv",
}
const enum LogLevel {
Info = "info",
Warn = "warn",
Error = "error",
}
// const enum gets replaced by "info" at compile time
console.log(LogLevel.Info);
4. Union Literal Types as an Alternative
A union literal type defines the allowed set of values purely in the type system, with no runtime representation whatsoever. At runtime, the values are plain strings or numbers, which greatly simplifies interoperability with plain JavaScript code, JSON APIs, and libraries without their own enum support.
Since no extra object is created, there's no bundle-size overhead at all. For discriminated unions, a central pattern in type-safe TypeScript code, union literal types are the natural choice anyway.
type ExportFormat = "json" | "xml" | "csv";
function exportData(format: ExportFormat) {
switch (format) {
case "json":
return toJson();
case "xml":
return toXml();
case "csv":
return toCsv();
}
}
5. Structural vs. Nominal Behavior
An often overlooked difference concerns type checking itself. Enums behave in a partially nominal way in TypeScript: two different enums with identical values are not mutually assignable, even when the underlying values match. Union literal types, by contrast, are purely structural, a string literal is always compatible no matter which context it came from.
This nominal behavior can actually serve as a feature in large codebases, preventing accidental mixing of similar value sets, but it also makes interoperability with external data harder.
Anyone wanting the best of both worlds often reaches for branded types, a technique built on top of union literal types that adds an artificial distinguishing marker to prevent accidental mixing of similar string types, without taking on the downsides of real enums.
6. JSON Serialization and API Boundaries
At API boundaries, for example when receiving JSON data from a backend, real enum instances never actually arrive, only plain strings or numbers. With union literal types this matches the runtime representation exactly, so no conversion is needed.
With enums, the raw value received must instead be explicitly checked against the allowed enum values or converted into the enum type, which requires extra code at every system boundary.
type Status = "draft" | "published" | "archived";
async function fetchStatus(): Promise<Status> {
const res = await fetch("/api/status");
const data = await res.json();
return data.status as Status; // directly compatible
}
7. When Enums Are Actually the Right Choice
Despite the drawbacks above, enums still have legitimate use cases. For bitflag patterns, where several values are combined via bitwise operations, numeric enums remain useful, since union literal types offer no native support for such combinations.
When true runtime iteration over all possible values is needed, for example to populate a dropdown menu dynamically, an enum object provides that directly, whereas a union literal type requires maintaining a separate constant listing all values.
8. Practical Recommendation
For most use cases in modern TypeScript code, especially discriminated unions, API response types, and configuration values, union literal types are the more pragmatic choice: no runtime overhead, straightforward serialization, and full compatibility with plain JavaScript.
When true runtime iteration over all values is required, a pattern combining an as const array with a union type derived from it offers the best of both worlds without needing a real enum.
const STATUSES = ["draft", "published", "archived"] as const;
type Status = (typeof STATUSES)[number];
// STATUSES.map(...) enables runtime iteration
// Status remains a pure union literal type
9. Migration Pitfalls
When replacing existing enums with union literal types, keep in mind that const enum values are often compared directly against numbers or strings in downstream code. A migration therefore requires a careful search for every usage site, especially for numeric enums, whose concrete values are often implicitly assumed.
Another pitfall: const enum doesn't work in projects using isolatedModules, which has even become the default for many standard configurations since TypeScript 5.0. Anyone wanting to keep using const enum has to explicitly disable that option, which can in turn limit other build tools.
Tests deserve a second look too: snapshot tests relying on the reverse mapping of numeric enums often break silently when the order of enum members changes. Union literal types naturally avoid this risk, since they carry no automatically generated numeric values.
| Aspect | Numeric enum | String enum | const enum | Union literal type |
|---|---|---|---|---|
| Runtime object | Yes, with reverse mapping | Yes | No, replaced inline | No |
| Bundle overhead | Yes | Yes | None | None |
| Compatible with isolatedModules | Yes | Yes | No | Yes |
| JSON serialization | Requires conversion | Requires conversion | Directly compatible | Directly compatible |
| Type checking | Partially nominal | Partially nominal | Partially nominal | Purely structural |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
Enums vs. Union Literals
Enum runtime overhead
Real object per enum
Union runtime overhead
None, pure type system
const enum limitation
Incompatible with isolatedModules
Recommendation
Union literals as the default choice