Enums or unions? I've flip-flopped on this more times than I'll admit. I think I've finally landed somewhere.
Early on, I used enums for everything. They felt official — like the "right" TypeScript way to define a set of values. Then I started noticing weird things: bundle sizes creeping up, type safety gaps I didn't expect, and teammates getting confused about when to pass a raw string versus an enum reference. So I swung hard the other direction, banned enums from a project entirely, and used union types for everything. That had its own annoyances.
After a couple years of this, I think the answer is boring: it depends on what you're doing. But the specifics of why it depends are worth understanding. Let me walk through each option and what actually happens when your code compiles.
Numeric Enums: The Original (and Problematic) Option
Numeric enums are what TypeScript shipped with from the start. They give each member an auto-incrementing number:
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
const move = Direction.Up; // 0You can set custom values too, and subsequent members increment from there:
enum HttpStatus {
Ok = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
InternalServerError = 500,
}Here's the thing that bugged me when I first saw it. Look at what TypeScript actually generates:
// Compiled JavaScript output
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 = {}));That IIFE creates a reverse mapping — Direction[0] gives you "Up" and Direction.Up gives you 0. Neat for debugging, sure, but bundlers like Webpack, Rollup, and esbuild can't tree-shake this. Every numeric enum ends up in your production bundle whether you use all its members or not.
But here's the really bad part. TypeScript lets you do this:
enum Direction {
Up,
Down,
Left,
Right,
}
const d: Direction = 42; // No error! This compiles fine.Any number passes the type check. That completely defeats the purpose of constraining values. This quirk alone is why I stopped using numeric enums years ago.
I ran into this on a project where we had a Status enum for order processing. A junior dev accidentally passed a raw HTTP status code (200) where the function expected a Status enum value. TypeScript said nothing. The bug slipped into production and silently corrupted a batch of orders. We only caught it because a QA engineer noticed order statuses showing up as numbers in an admin dashboard. That was the last time I used a numeric enum in application code.
String Enums: Better, but Still Heavy
String enums came along in TypeScript 2.4 and fixed the worst problems with numeric enums. Every member needs an explicit string value:
enum LogLevel {
Debug = "DEBUG",
Info = "INFO",
Warn = "WARN",
Error = "ERROR",
}
function log(level: LogLevel, message: string): void {
console.log(`[${level}] ${message}`);
}
log(LogLevel.Info, "Server started"); // [INFO] Server startedNo reverse mapping this time, so the compiled output is simpler:
var LogLevel;
(function (LogLevel) {
LogLevel["Debug"] = "DEBUG";
LogLevel["Info"] = "INFO";
LogLevel["Warn"] = "WARN";
LogLevel["Error"] = "ERROR";
})(LogLevel || (LogLevel = {}));And you can't sneak in arbitrary strings:
const level: LogLevel = "DEBUG"; // Error: Type '"DEBUG"' is not assignable to type 'LogLevel'This is both good and annoying. The runtime value of LogLevel.Debug is the string "DEBUG", but TypeScript treats them as different types. You have to use the enum reference. That's nice for internal code where you control all the call sites. It's a pain when you're parsing API responses or database rows that come in as plain strings.
The bundle size problem remains, though. That IIFE wrapper looks like it has side effects, so bundlers won't remove it.
There is one situation where string enums earned their keep in my experience: when working with a team that included backend developers who were not TypeScript-native. The enum syntax reads clearly and the dot-notation access felt familiar to people coming from C# or Java. The overhead of that IIFE wrapper is genuinely trivial in a server-side context — you are not shipping this to a browser, nobody cares about a few extra bytes in a Node process. Context matters more than dogma here.
Const Enums: Clever but Fragile
Const enums take a different approach — the compiler just inlines values everywhere and generates no runtime object at all:
const enum Color {
Red = "#FF0000",
Green = "#00FF00",
Blue = "#0000FF",
}
const primary = Color.Red;
// Compiles to: const primary = "#FF0000";Zero bundle overhead. That sounds great. In practice, the restrictions killed it for me:
- No
Object.keys()orObject.values()— there's no object at runtime to iterate. - They break with
isolatedModules, which Babel, esbuild, and SWC all require. Each file gets compiled on its own, so the compiler can't inline values from other files. - They don't play well with
declarationfile generation in some setups, which is a headache if you're publishing a library. - The TypeScript team themselves recommend against using const enums in libraries.
If you're building an app (not a library), using tsc directly (not Babel or esbuild), and you don't need isolatedModules — const enums work fine. That's a narrow set of conditions, though.
Union Types and as const: Where I Usually End Up
A simple string literal union looks like this:
type Direction = "up" | "down" | "left" | "right";
function move(direction: Direction): void {
console.log(`Moving ${direction}`);
}
move("up"); // OK
move("diagonal"); // Error: Argument of type '"diagonal"' is not assignableThis generates absolutely nothing at runtime. The type annotation gets stripped out, and you're left with plain strings. No bundle impact at all.
The tradeoff is obvious: there's no runtime object. You can't iterate over the possible values, build a dropdown from them, or validate input against them. That's where as const comes in:
const DIRECTIONS = ["up", "down", "left", "right"] as const;
type Direction = (typeof DIRECTIONS)[number];
// type Direction = "up" | "down" | "left" | "right"
// Now you can iterate at runtime
for (const dir of DIRECTIONS) {
console.log(dir);
}
// And validate input
function isDirection(value: string): value is Direction {
return (DIRECTIONS as readonly string[]).includes(value);
}The as const assertion tells TypeScript to infer the narrowest type — a readonly tuple of literal strings instead of string[]. Then you pull the union type out with an indexed access. You get both a runtime array and a compile-time type with almost no duplication.
Want the dot-notation feel of enums? Use an object:
const LogLevel = {
Debug: "DEBUG",
Info: "INFO",
Warn: "WARN",
Error: "ERROR",
} as const;
type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
// type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"
function log(level: LogLevel, msg: string) {
console.log(`[${level}] ${msg}`);
}
log(LogLevel.Info, "Server started"); // Works just like an enum
log("WARN", "Disk space low"); // Also works with plain stringsI refactored an entire codebase from string enums to as const objects once. About 40 enums across a mid-sized app. The actual refactor took half a day — mostly find-and-replace plus fixing the spots where code relied on the enum being an opaque type rather than accepting plain strings. The import structure stayed almost identical. What surprised me was that several bugs surfaced during the migration: places where we were doing unnecessary enum-to-string conversions before sending data to the API, or awkward parsing logic to turn API strings back into enum values. With as const, those layers just disappeared because the values are plain strings.
This is where I end up most of the time. You get enum-style dot notation, plain strings work too, Object.values(LogLevel) gives you the runtime list, and bundlers can tree-shake a plain object with no trouble.
Discriminated Unions: When Enums Aren't Even the Right Question
Sometimes when people reach for enums, what they actually need is a discriminated union. These are different in kind, not just in style:
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape = Circle | Rectangle | Triangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
}
}TypeScript narrows the type in each case branch, so you get full autocomplete and type checking. The real magic is the exhaustiveness check — if you add a new shape but forget to handle it, you can catch that at compile time:
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape); // Compile error if a case is missing
}
}I mention this because I've seen people model things like API response states, UI modes, and event types as enums when a discriminated union would serve them much better. Each variant can carry its own data. An enum can't do that.
One pattern I keep coming back to is combining discriminated unions with as const for the kind values. If you have a list of valid kind strings scattered across interface declarations, it is easy to lose track of them. I will often define the kinds in one place:
const SHAPE_KINDS = ["circle", "rectangle", "triangle"] as const;
type ShapeKind = (typeof SHAPE_KINDS)[number];Then each interface uses a specific literal from that set. It keeps everything anchored to a single source of truth, and you still get the full narrowing behavior in switch statements.
So What Should You Actually Pick?
Here's where I've landed after going back and forth on this for too long:
Start with string union types if you just need a set of string constants. They're simple, they generate no code, and they play nice with data coming from APIs or databases. This covers probably 70% of the cases I run into.
Upgrade to an as const object when you need runtime access to the values — iterating, validating input, building UI elements from the list. You get the enum-like dot notation as a bonus. This is my go-to for anything that needs to exist at runtime.
Use string enums if your team has standardized on them and everyone's used to the pattern. Consistency across a codebase matters more than saving a few bytes. I wouldn't fight this battle on a team that's already committed. They're also reasonable in backend Node.js code where bundle size doesn't matter at all.
Skip numeric enums. The type safety hole (any number being assignable) is a real footgun. If you need numeric constants, an as const object does the same thing without the gotcha.
Skip const enums unless your build setup specifically supports them and you're not publishing a library. Too many edge cases with modern tooling.
The TypeScript community has been drifting toward union types and as const for a while now. A lot of popular open-source projects have lint rules that discourage or ban enums. That doesn't mean enums are wrong — they're fine when they fit. But the alternatives have gotten good enough that enums aren't the default choice anymore.
Use what fits. Move on. This isn't the hill to die on.
Comments (0)
No comments yet. Be the first to share your thoughts!