Enums: Numeric, String, and Const
Understand how TypeScript enums work at compile time and at runtime, when to reach for each kind, and why many modern codebases prefer union-of-literal types instead.
An enum lets you define a fixed set of named constants — like a list of statuses or directions — under a single type name. Unlike most TypeScript features, enums are not purely compile-time: they generate real runtime JavaScript code, which makes them behave differently from almost everything else you've learned so far.
Numeric enums and auto-incrementing values
A numeric enum is the original enum style carried over from languages like C# and Java. By default, the first member is assigned 0, and each subsequent member auto-increments by 1 unless you assign your own value. enum Direction { Up, Down, Left, Right } gives Direction.Up = 0, Direction.Down = 1, and so on.
You can start the numbering anywhere by assigning the first member explicitly — enum Direction { Up = 1, Down, Left, Right } makes Up 1 and the rest count up from there. You can also assign every member its own arbitrary number, which is common when the values need to match an external system, like HTTP status codes or a legacy database schema.
Numeric enums compile to a real JavaScript object, and TypeScript additionally generates a 'reverse mapping' — you can go from the name to the number (Direction.Up → 1) and from the number back to the name (Direction[1] → "Up"). This reverse mapping is convenient for debugging and logging but doubles the size of the generated object and is a source of confusion for newcomers.
String enums
A string enum requires every member to be explicitly initialized with a string literal — there is no auto-incrementing, because there's no sensible default for a string. This is the pattern shown in the required example: enum Status { Idle = "idle", Loading = "loading", Success = "success", Error = "error" }.
String enums are generally preferred over numeric enums when they're used at all, because the runtime value is self-descriptive. If you log Status.Loading, you see "loading" in your console or network payload, not an opaque 1 that requires cross-referencing the enum definition to understand.
Critically, string enums do not get a reverse mapping. Status["idle"] does not give you back "Idle" — string enum objects only map forward, from member name to string value. This asymmetry with numeric enums is a common source of confusion.
Const enums and compile-time inlining
Prefixing an enum with const — const enum Direction { Up, Down, Left, Right } — tells the compiler to completely erase the enum at compile time and inline the literal values everywhere they're used. Direction.Up in your source becomes the literal 0 in the compiled JavaScript, and no runtime object is generated at all.
This produces smaller, faster output because there's zero runtime overhead, but it comes with real constraints: const enums cannot be used with certain module patterns (notably isolatedModules, which many modern build tools like esbuild and SWC require), and you lose the reverse-mapping object and any ability to iterate over the enum's values at runtime. Many teams and linters actively discourage const enums for this compatibility reason.
Enum pitfalls and why unions are often preferred
Numeric enums have a well-known type-safety hole: any number is assignable to a numeric enum type by default. let d: Direction = 999; compiles without error even though 999 isn't a valid Direction member. String enums do not have this hole — only an actual member of the enum, or another value of that same enum type, is assignable.
Enums also generate real runtime code, meaning they add to your bundle size and exist as actual objects your code depends on — this is unusual for a type-system feature and surprises developers coming from a 'types disappear at compile time' mental model.
For these reasons, a large and growing share of modern TypeScript codebases — including official guidance from the TypeScript team itself — favor a union of string literal types over an enum: type Status = "idle" | "loading" | "success" | "error";. This produces zero runtime code, has no numeric-assignability hole, works everywhere isolatedModules is required, and is arguably just as readable. You'll still encounter enums constantly in existing/legacy codebases, so understanding them is essential even if you choose unions for new code.
Enum declaration forms
Numeric enums support reverse mapping (Direction[0] === "Up"); string enums do not. const enum is erased entirely at compile time — no runtime object exists. A union-of-literals type has zero runtime footprint and is the recommendation in most modern style guides.
// Numeric enum (auto-incrementing)
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right // 3
}
// String enum
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
Error = "error"
}
// Const enum (erased at compile time)
const enum Direction2 {
Up,
Down
}
// Modern alternative: union of string literals
type StatusUnion = "idle" | "loading" | "success" | "error";Code Examples
The Status string enum defines four named states. Using Status.Loading instead of the raw string "loading" gives autocomplete and prevents typos like "loding" from ever compiling. Expected result: In progress
Because Up = 1 was set explicitly, the rest auto-increment from there. The reverse mapping lets you go from the number 2 back to the string "Down", but this only works for numeric enums, not string enums.
Common mistake: Assigning let unsafe: Direction = 999; silently compiles even though 999 isn't a real member — this is a known gap in numeric enum type safety that string enums and union types both close.
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
Error = "error"
}
function describe(status: Status): string {
switch (status) {
case Status.Idle:
return "Waiting to start";
case Status.Loading:
return "In progress";
case Status.Success:
return "Completed";
case Status.Error:
return "Failed";
}
}
console.log(describe(Status.Loading));enum Direction {
Up = 1,
Down,
Left,
Right
}
console.log(Direction.Down); // 2
console.log(Direction[2]); // "Down" (reverse mapping)
let unsafe: Direction = 999; // Compiles! No error — numeric enums accept any number.Enum vs union: what survives to runtime
An enum leaves behind a real runtime object; a union of literal types leaves behind nothing at all.
SOURCE (TypeScript)
enum Status { Idle="idle", ... } type StatusU = "idle" | "loading" | ...
| compiles to | compiles to
v v
COMPILED JAVASCRIPT
var Status; (nothing — erased entirely)
(function (Status) {
Status["Idle"] = "idle";
...
})(Status || (Status = {}));Important Rules
Use a string enum, not a numeric enum, when you need an enum at all — string enums avoid the numeric-assignability hole and produce self-describing runtime values.
Avoid const enum in new code if your build tool uses isolatedModules (most modern bundlers do) — it will fail to compile.
For new code, default to a union of string literal types unless you specifically need enum features like iteration over all members or a namespace-like grouping.
Never rely on numeric enum reverse mapping in application logic — it's a debugging convenience, not a designed API.
Where this shows up
You'll see numeric and string enums throughout older enterprise codebases, generated code (like TypeScript definitions produced from a GraphQL schema or an OpenAPI spec), and some popular libraries' public APIs. New projects increasingly favor string literal unions for component props (type ButtonVariant = "primary" | "secondary" | "danger") and Redux-style action types, precisely because they add zero bundle size and integrate cleanly with modern build tooling.
enum Direction { Up, Down } function move(d: Direction) { /*... */ } move(5); // compiles, but 5 isn't Up or Down type Direction = "up" | "down"; function move(d: Direction) { /*... */ } move("left"); // correctly errors: not assignable to Direction
Numeric enums allow any number to be assigned to the enum type, silently permitting invalid values; a string literal union rejects anything outside the defined set.
[multiple-choice] What is a key difference between numeric enums and string enums in TypeScript? A. String enums cannot have more than 4 members B. Numeric enums get an automatic reverse mapping (value to name); string enums do not C. String enums are erased at compile time; numeric enums are not D. There is no difference — both behave identically
B. Numeric enums get an automatic reverse mapping (value to name); string enums do not — Numeric enums generate a bidirectional lookup object at runtime, but string enums only map forward from member name to string value.
[predict-output] What happens when you write let unsafe: Direction = 999; for a numeric enum Direction { Up, Down }? A. TypeScript throws a compile error immediately B. It compiles without error, even though 999 isn't a valid member C. It throws a runtime exception D. TypeScript automatically clamps it to the nearest valid member
B. It compiles without error, even though 999 isn't a valid member — This is a well-known gap in numeric enum type safety — any number is assignable to a numeric enum type, unlike string enums or union types.
[conceptual] Why do many modern TypeScript style guides recommend union-of-literal types over enums? A. Because enums are deprecated and will be removed from TypeScript B. Because unions produce zero runtime code, avoid the numeric-assignability hole, and work with tools requiring isolatedModules C. Because enums cannot be used in switch statements D. Because unions are faster to type-check
B. Because unions produce zero runtime code, avoid the numeric-assignability hole, and work with tools requiring isolatedModules — Unions compile away completely, sidestep numeric enums' type-safety gap, and are compatible with modern isolated-module build pipelines that const enums often break.
Rewrite the Status string enum from this lesson as a union of string literal types, then write a function isTerminal(status) that returns true for "success" or "error" and false otherwise, using the union type as the parameter type.
type Status = "idle" | "loading" | "success" | "error"; function isTerminal(status: Status): boolean { return status === "success" || status === "error"; }
The union type declaration is just type Status = "idle" | "loading" | "success" | "error"; — no enum keyword needed.