Technology  /  TypeScript

๐Ÿ”ท TypeScript 16 guides ยท updated 2026

Type erasure, structural typing, generics, and the type-level programming toolkit โ€” TypeScript taught the way it actually behaves at compile time and at runtime.

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); // 0

By 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); // 0
console.log(Direction[0]); // "Up" โ€” reverse mapping, genuinely useful for logging/debugging

String 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 mapping
var 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 all
let 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).

enum Direction { Up, Down }

Regular enum:

generates a real object,

supports reverse mapping

const enum Direction { Up, Down }

Const enum:

fully inlined at every use site,

zero runtime object, zero overhead


Enums vs Literal Unions โ€” The Decision That Matters Most

// Enum approach
enum Role {
Admin = "ADMIN",
Editor = "EDITOR",
Viewer = "VIEWER",
}
function checkAccess(role: Role) { ... }
checkAccess(Role.Admin);
// Literal union approach
type RoleUnion = "ADMIN" | "EDITOR" | "VIEWER";
function checkAccessUnion(role: RoleUnion) { ... }
checkAccessUnion("ADMIN"); // no import needed, no namespace to reference

This 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 entirely
setStatus(9999); // Also works โ€” TypeScript doesn't restrict numeric enums to only declared values in this context

This 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 iterate
const 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 runtime
declare 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 cost

This 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 typeReverse mappingRuntime footprintNumeric enum safety issue
Numeric enumYesReal object generatedAccepts any number, not just declared members
String enumNoReal object generatedN/A โ€” string enums donโ€™t have this gap
const enumNoFully inlined, no object at allSame numeric issue if declared without string values
Literal unionN/A โ€” not an enumNone โ€” fully erasedRejects 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.