Enums in TypeScript: Numeric, String & Const Enums Explained
Enums are one of the few TypeScript features that isnโt purely erased at compile time โ depending on which kind you use, an enum generates real, non-trivial JavaScript that persists at runtime, unlike interfaces, type aliases, and most of what this series has covered so far. That makes enums the odd one out in TypeScriptโs type system, and understanding exactly what gets generated (and why a const enum is different) matters more than for almost any other construct.
Numeric Enums
enum Direction { Up, Down, Left, Right,}
let move: Direction = Direction.Up;console.log(move); // 0By default, a numeric enum auto-assigns increasing integers starting from 0 โ Up is 0, Down is 1, Left is 2, Right is 3. You can override the starting point or any individual member:
enum HttpStatus { OK = 200, Created = 201, BadRequest = 400, NotFound = 404, ServerError = 500,}
function handleResponse(status: HttpStatus) { if (status === HttpStatus.NotFound) { console.log("Resource not found"); }}
handleResponse(HttpStatus.NotFound);What a numeric enum actually compiles to
// Your TypeScript:enum Direction { Up, Down,}// Compiled JavaScript output:var Direction;(function (Direction) { Direction[Direction["Up"] = 0] = "Up"; Direction[Direction["Down"] = 1] = "Down";})(Direction || (Direction = {}));This is genuinely surprising the first time you see it: Direction compiles into a real JavaScript object with entries in both directions โ Direction.Up gives you 0, but Direction[0] also gives you back "Up". This is called reverse mapping, and itโs a real runtime feature, not a compile-time-only convenience:
console.log(Direction.Up); // 0console.log(Direction[0]); // "Up" โ reverse mapping, genuinely useful for logging/debuggingString Enums
enum Status { Pending = "PENDING", Active = "ACTIVE", Closed = "CLOSED",}
console.log(Status.Active); // "ACTIVE"console.log(Status["ACTIVE"]); // Error: no reverse mapping for string enums// Compiled output โ much simpler than numeric enums, no reverse mappingvar Status;(function (Status) { Status["Pending"] = "PENDING"; Status["Active"] = "ACTIVE"; Status["Closed"] = "CLOSED";})(Status || (Status = {}));String enums donโt support reverse mapping โ thereโs no meaningful way to reverse-map a string enum without ambiguity if two members happened to share a value, so TypeScript simply doesnโt generate that direction for strings. String enum values are also much more debuggable in practice: logging or serializing Status.Active gives you the readable "ACTIVE" directly, whereas a numeric enum value serializes to a bare, meaningless 1 unless something explicitly reverse-maps it back to a name first.
Const Enums โ Zero Runtime Footprint
const enum Direction { Up, Down, Left, Right,}
let move = Direction.Up;// Compiled output with a const enum โ the enum object doesn't exist at alllet move = 0 /* Direction.Up */;const enum is fully inlined at every usage site during compilation โ the entire enum object from the regular-enum examples above never gets generated at all. Every reference to Direction.Up is replaced directly with the literal 0, which means zero runtime overhead: no object allocation, no lookup, nothing. The trade-off: you lose reverse mapping entirely (thereโs no object left to look values up in), and โ more importantly for real projects โ const enum cannot be used across separate compilation contexts the way plain enums can (an issue for certain bundler/isolatedModules setups, which is why some build tools and TypeScriptโs own isolatedModules flag explicitly disallow const enum altogether).
Enums vs Literal Unions โ The Decision That Matters Most
// Enum approachenum Role { Admin = "ADMIN", Editor = "EDITOR", Viewer = "VIEWER",}
function checkAccess(role: Role) { ... }checkAccess(Role.Admin);
// Literal union approachtype RoleUnion = "ADMIN" | "EDITOR" | "VIEWER";
function checkAccessUnion(role: RoleUnion) { ... }checkAccessUnion("ADMIN"); // no import needed, no namespace to referenceThis is genuinely one of the more debated stylistic choices in modern TypeScript, and the languageโs own team has published guidance leaning toward preferring literal unions over enums for most cases. The reasons:
Enums require an import and a namespace reference; unions donโt. Role.Admin requires importing Role from wherever itโs defined. "ADMIN" as a literal is just a plain string โ it works seamlessly with plain JavaScript objects, JSON payloads, and API boundaries without any enum object needing to exist on the receiving end.
Enums (non-const) generate real runtime code; unions generate none. Every regular enum adds actual JavaScript to your bundle โ modest for a small enum, but a genuine (if usually small) cost that a literal union never incurs, since unions are fully erased.
Numeric enums are structurally unsafe in a way string enums and unions arenโt:
enum Status { Active, Inactive,}
function setStatus(status: Status) { ... }setStatus(0); // Works! Any number is assignable to a numeric enum โ this bypasses the "enum" safety entirelysetStatus(9999); // Also works โ TypeScript doesn't restrict numeric enums to only declared values in this contextThis is a real, cited gap: numeric enums accept any number as assignable, not just the declared members โ undermining the entire point of restricting a parameter to a fixed set of values. String enums and literal unions donโt have this problem: setStatus("banana") against a "Active" | "Inactive" union is correctly rejected.
Where enums still make sense: when you specifically want the namespaced grouping (Role.Admin reads more explicitly as โa Roleโ than a bare "ADMIN" string does at the call site), or when reverse mapping is a feature you actually use (debugging, logging numeric codes back to names). For most new code โ API request/response shapes, Redux-style action types, configuration values โ a literal union, often combined with as const for a matching runtime array, covers the same ground with less overhead.
// The "as const" pattern โ get union-type safety with an actual runtime array/object to iterateconst ROLES = ["ADMIN", "EDITOR", "VIEWER"] as const;type Role = typeof ROLES[number]; // "ADMIN" | "EDITOR" | "VIEWER" โ derived, not duplicated
function isValidRole(value: string): value is Role { return (ROLES as readonly string[]).includes(value);}This pattern gets something enums canโt offer for free: a real runtime array (ROLES) you can .map() over to render a dropdown, validate against, or iterate โ while the Role type stays derived from that single array instead of being maintained separately.
Enums in Exhaustive Switch Statements
enum OrderStatus { Pending = "PENDING", Shipped = "SHIPPED", Delivered = "DELIVERED",}
function getStatusLabel(status: OrderStatus): string { switch (status) { case OrderStatus.Pending: return "Waiting to ship"; case OrderStatus.Shipped: return "On its way"; case OrderStatus.Delivered: return "Delivered"; default: const exhaustiveCheck: never = status; throw new Error(`Unhandled status: ${exhaustiveCheck}`); }}The same exhaustiveness-check pattern covered for discriminated unions applies identically to enums used in a switch โ if OrderStatus gains a new member (Cancelled, say) and this function isnโt updated to handle it, the default branchโs never assignment fails to compile, catching the gap immediately rather than letting getStatusLabel silently fall through to the error path at runtime for a real, valid new status.
Computed and Mixed Enum Members
enum FileAccess { None = 0, Read = 1 << 1, // 2 Write = 1 << 2, // 4 ReadWrite = Read | Write, // 6 โ computed from other members}Numeric enum members can be computed expressions referencing earlier members, not just literal numbers โ this bit-flag pattern (1 << 1, 1 << 2) is a common, genuinely useful case: each flag occupies a distinct bit, so they can be combined with | and tested with &, mirroring how permission systems are often modeled at a lower level. This is one of the few remaining scenarios where a numeric enumโs arithmetic-friendly nature is a real advantage over a string-based union.
Ambient Enums
// In a .d.ts declaration file, describing an enum that exists elsewhere at runtimedeclare enum ExternalStatus { Active, Inactive,}declare enum describes an enumโs shape for type-checking purposes without generating any implementation โ used specifically in .d.ts declaration files (covered in the next guide) to describe an enum that a JavaScript library defines at runtime, letting TypeScript check usage against it without redefining the actual values.
Object Enums (Common Pre-TypeScript-5 Alternative)
const Role = { Admin: "ADMIN", Editor: "EDITOR", Viewer: "VIEWER",} as const;
type Role = typeof Role[keyof typeof Role]; // "ADMIN" | "EDITOR" | "VIEWER"
function checkAccess(role: Role) { ... }checkAccess(Role.Admin); // "ADMIN" โ namespaced access, like an enum, but zero enum-specific runtime costThis pattern combines the readable, namespaced access style of an enum (Role.Admin instead of a bare string) with the โjust a plain objectโ runtime simplicity of the union approach โ Role here is an ordinary frozen-shape object (via as const), not a TypeScript-specific enum construct, so it behaves predictably across any tooling that doesnโt know what a TypeScript enum is.
Enums Quick Reference
| Enum type | Reverse mapping | Runtime footprint | Numeric enum safety issue |
|---|---|---|---|
| Numeric enum | Yes | Real object generated | Accepts any number, not just declared members |
| String enum | No | Real object generated | N/A โ string enums donโt have this gap |
const enum | No | Fully inlined, no object at all | Same numeric issue if declared without string values |
| Literal union | N/A โ not an enum | None โ fully erased | Rejects any value outside the declared union |
Frequently Asked Questions
Should new TypeScript projects avoid enums entirely? Not entirely, but the general modern guidance leans toward literal unions (or the as const object pattern) as the default, reserving enums for cases where the namespaced access style or genuine reverse mapping is specifically wanted.
Why does a numeric enum accept any number as valid, defeating the point of restricting values? This is a known, documented quirk in TypeScriptโs numeric enum design, rooted in numeric enums historically being treated as more of a โnamed numberโ convenience than a strict closed set. String enums and literal unions donโt share this gap, which is one of the concrete reasons theyโre generally preferred.
Is const enum always safe to use? Not universally โ itโs disallowed under certain build configurations (isolatedModules: true, used by many single-file transpilers like esbuild and swc that process files independently and canโt inline across module boundaries the way tsc can). Check your specific build toolโs compatibility before relying on const enum in a new project.
Do string enums have any downside compared to literal unions? Mainly the import/namespace requirement and the (small but nonzero) generated runtime object โ for most application code, this is a minor cost, but itโs the reason unions remain the more common recommendation for pure data-shape modeling.
Can I mix enum members with different underlying types? Numeric and string enum members can technically coexist in one enum (โheterogeneous enumsโ), but this is explicitly discouraged in TypeScriptโs own documentation โ it defeats the predictability enums are meant to provide, and thereโs essentially no real use case that a well-modeled string enum or discriminated union wouldnโt handle more clearly.
Whatโs Next
Enums close out the object-oriented section of this series โ you now know when a runtime-generating construct like a class or enum is the right call, versus when a purely compile-time construct (union, interface, type alias) fits better. Next: Modules and Declaration Files, covering how TypeScript code is actually organized across files, and how types get attached to JavaScript libraries that were never written with TypeScript in mind.