TypeScript Enums Explained (and When to Avoid Them)
TypeScript enums group named constants under one type. How numeric, string, and const enums compile, and when a union type is the better choice.
A TypeScript enum is a language construct that groups a fixed set of named constants under a single type — Direction.Up, Direction.Down — giving you named values instead of magic numbers or scattered string literals. Enums are one of the few TypeScript features with no direct JavaScript equivalent, which is exactly why they’re also one of the more debated ones.
Numeric enums
The default enum auto-assigns increasing numbers starting at 0:
enum Direction {
Up,
Down,
Left,
Right,
}
const move: Direction = Direction.Up; // 0
Numeric enums are bidirectional at runtime — you can go from Direction.Up to 0, but the compiled object also lets you go from 0 back to "Up" via reverse mapping. That convenience comes at a cost: the values are just numbers underneath, so Direction.Up === 0 is true, and nothing stops a stray 2 from being assigned where a Direction is expected in less strict configurations.
String enums
String enums require an explicit value for every member and don’t get reverse mapping:
enum Status {
Pending = "PENDING",
Active = "ACTIVE",
Closed = "CLOSED",
}
These are generally easier to debug — logging or serializing a Status.Active value shows "ACTIVE", not an opaque 1 — and they’re a closer match for values that cross a network boundary, like a status field from an API response.
const enums
Prefixing with const tells the compiler to inline the enum’s values at every usage site and erase the enum object entirely from the compiled output:
const enum Level {
Low,
Medium,
High,
}
This produces zero runtime code — no object, no property lookups — at the cost of losing the ability to iterate over the enum’s members or use it across certain module boundaries (const enum doesn’t work with isolatedModules, which most modern bundlers and tools like esbuild and swc enable by default). Because of that restriction, const enum has become the enum variant most likely to break a build in a modern toolchain, so plenty of teams avoid it entirely.
Enums vs union types
TypeScript’s structural type system means you often don’t need a runtime construct at all. A union of string literals gives you the same compile-time safety with no compiled output:
type Status = "PENDING" | "ACTIVE" | "CLOSED";
function handle(status: Status) {
// status is narrowed to one of the three literals
}
| Numeric/string enum | Union of literal types | |
|---|---|---|
| Runtime footprint | Generates a JS object | None — erased entirely |
Works with isolatedModules | Yes (except const enum) | Yes |
Exhaustiveness checking in switch | Yes | Yes |
| Iterable at runtime | Yes | No (values only exist in types) |
| Serializes cleanly to JSON | String enums: yes: numeric: no | Yes, if literals are strings |
If you never need to iterate over the set of values at runtime, a union type is usually the better default: it’s erased completely, avoids the const enum bundler pitfalls, and composes naturally with other TypeScript features like discriminated unions and conditional types. Reach for an actual enum when you specifically need a real object to iterate, pass around, or attach to at runtime — for instance, populating a dropdown from all defined values.
Enums as discriminants
One place enums do pull their weight is as the discriminant field in a tagged union, working the same way a string literal union would:
enum Shape {
Circle,
Square,
}
type Figure =
| { kind: Shape.Circle; radius: number }
| { kind: Shape.Square; side: number };
function area(figure: Figure): number {
switch (figure.kind) {
case Shape.Circle:
return Math.PI * figure.radius ** 2;
case Shape.Square:
return figure.side ** 2;
}
}
TypeScript narrows figure correctly inside each case, the same exhaustiveness checking you’d get from a plain string union applies, and tools that expect enum members by convention (some ORMs and validation libraries mirror database enum types this way) have a natural home for the value.
Practical guidance
- Prefer string enums over numeric enums when you need an enum at all — the runtime values are self-describing and safer to log, store, or send over the wire.
- Avoid
const enumunless you fully control the build pipeline and have verifiedisolatedModulescompatibility; it’s the single most common enum-related build failure in modern toolchains. - Default to a union of string literals for anything that doesn’t need runtime iteration — which is most application-level status fields, modes, and categories. It also plays more cleanly with
satisfies, covered in our piece on thesatisfiesoperator. - If you’re consuming a value from an external API or a database column with a fixed set of states, a string union combined with runtime validation (or the schema types described in what JSON Schema is) typically covers the same ground as an enum with less compiled overhead.
The takeaway
TypeScript enums group named constants under one type, with numeric enums auto-numbering from 0, string enums requiring explicit values, and const enum inlining everything at the cost of bundler compatibility. For most application code, a union of string literal types gets you the same compile-time safety with zero runtime footprint — reach for a real enum only when you need to iterate over or pass around the actual value set at runtime.
Keep reading
Takina · · 4 min read TypeScript Abstract Classes, Explained
Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.
Takina · · 3 min read TypeScript's never Type, Explained
never represents values that can't exist — it marks unreachable code, exhaustive switches, and functions that always throw or loop forever.
Takina · · 4 min read TypeScript readonly Modifiers Explained
TypeScript's readonly keyword blocks reassignment at compile time for properties, arrays, and tuples — with no runtime enforcement at all.