The Problem Enums Try to Solve
Every application ends up with small fixed sets of values: an order is placed, packed, shipped or delivered; a user is an admin, a teacher or a student. Written as bare strings scattered through the codebase, these are known as magic values, and they cause two predictable problems. A typo — "shiped" — is silently accepted and produces a bug you find in production, and when the set changes you have no way to find every place that uses it.
An enum gathers those values into a single named group, so you write Status.Shipped instead of a raw string. The name is checked, your editor autocompletes it, renaming works properly, and there is exactly one place to look when the list changes.
That is the goal. TypeScript now has two ways to reach it — the enum keyword, and a plain object marked as const — and modern codebases increasingly choose the second. This lesson covers enums properly, because you will meet them in real projects and in every older tutorial, and then shows the alternative honestly so you can pick.
// The problem: nothing checks these strings
function updateOrder(id: string, status: string) { /* ... */ }
updateOrder("A-1", "shipped");
updateOrder("A-2", "shiped"); // typo, accepted, wrong data saved
updateOrder("A-3", "Shipped"); // different case, also accepted
// The goal: one named set, checked by the compiler
enum Status {
Placed = "placed",
Packed = "packed",
Shipped = "shipped",
Delivered = "delivered"
}
function updateOrderSafe(id: string, status: Status) { /* ... */ }
updateOrderSafe("A-1", Status.Shipped); // OK
updateOrderSafe("A-2", "shipped");
// ERROR: Argument of type '"shipped"' is not assignable to parameter of type 'Status'. Numeric Enums and the Reverse-Mapping Surprise
By default an enum's members are numbers, counting up from zero. You can set the numbers yourself, which is genuinely useful when they mean something outside your program — HTTP status codes are the standard example.
Numeric enums do something extra that catches people out: TypeScript builds a reverse mapping into the generated object, so the object contains both Up → 0 and 0 → "Up". Being able to recover the name from the number is occasionally handy, but the object now has twice as many keys as you wrote. Loop over it with Object.keys and you get the numbers as well as the names, which is why an innocent-looking dropdown built from an enum ends up with eight options instead of four.
The other weakness of numeric enums is that the underlying value is meaningless. Store 2 in your database and nobody reading that row can tell what it means, and if a teammate later inserts a new member in the middle of the enum, every stored number now points at the wrong thing. Reserve numeric enums for cases where the number itself is the real-world value, and use string enums for everything else.
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right // 3
}
console.log(Direction.Up); // 0
console.log(Direction[0]); // "Up" <- the reverse mapping
// The surprise
console.log(Object.keys(Direction));
// ["0", "1", "2", "3", "Up", "Down", "Left", "Right"]
// Filtering it back out is the usual workaround
const names = Object.keys(Direction).filter(k => isNaN(Number(k)));
// ["Up", "Down", "Left", "Right"]
// Numbers that genuinely mean something: a fair use
enum HttpStatus {
Ok = 200,
NotFound = 404,
ServerError = 500
}
const res = await fetch("/api/orders");
if (res.status === HttpStatus.NotFound) {
console.log("No such order");
} - String enums do not get a reverse mapping —
Status["placed"]is not valid — which is one more reason they behave more predictably.
String Enums Are the Ones You Want
A string enum requires you to give every member an explicit value. That feels like extra typing until the first time you read a log file or a database row and see "shipped" instead of 2. The value carries its own meaning wherever it travels, which matters because your data outlives your code.
String enums are also stricter in a way that is easy to miss. TypeScript's type system is normally structural — shapes are compared, names are ignored — but enums are one of the few places where it behaves nominally. A parameter typed Status will not accept the string "shipped", even though that is exactly the value Status.Shipped holds. You must write the enum member.
That strictness is a real advantage inside your own code, because it stops loose strings leaking in. It becomes an inconvenience at the boundaries, where data genuinely arrives as a plain string — a JSON response, a form field, a URL parameter. There you have to convert deliberately, and the honest way to do it is to check the value at runtime rather than assert it with as, which would only silence the compiler.
enum Role {
Admin = "admin",
Teacher = "teacher",
Student = "student"
}
function grantAccess(role: Role) {
console.log(`Access for ${role}`); // logs "admin", not 0
}
grantAccess(Role.Admin); // OK
grantAccess("admin");
// ERROR: Argument of type '"admin"' is not assignable to parameter of type 'Role'.
// Converting a plain string from outside — check, do not assert
function toRole(value: string): Role | null {
return Object.values(Role).includes(value as Role) ? (value as Role) : null;
}
const fromApi = toRole("teacher"); // Role | null
if (fromApi) grantAccess(fromApi); Enums Are Real Objects at Runtime
Almost everything in TypeScript disappears when you compile. Enums are one of the very few exceptions: enum emits an actual JavaScript object into your output file. This is why you can write Object.values(Role) and get a list back at runtime, and why an enum takes up space in your bundle even if you only use one member of it.
That dual nature is worth being clear about, because it explains several odd behaviours at once. An enum creates both a type (usable in annotations) and a value (usable in expressions) under the same name. It cannot be imported with import type, since you need the runtime object. And because bundlers cannot always tell which members you used, an unused enum member is often kept in the final build.
The comparison is straightforward: a union of string literals is pure type information and vanishes completely, while an enum leaves an object behind. Whether that object is a benefit or a cost depends on whether you actually need to iterate over the values at runtime.
// What tsc emits for a string enum (simplified):
//
// var Role;
// (function (Role) {
// Role["Admin"] = "admin";
// Role["Teacher"] = "teacher";
// Role["Student"] = "student";
// })(Role || (Role = {}));
//
// A real object. It exists in the browser.
console.log(Object.values(Role)); // ["admin", "teacher", "student"]
// Which makes this possible — building a dropdown from the enum
const options = Object.values(Role).map(r => ({ value: r, label: r }));
// A union of literals, by contrast, emits nothing at all
type RoleUnion = "admin" | "teacher" | "student";
// There is no RoleUnion at runtime — you cannot iterate over it - If you need the list of values at runtime, you need something that exists at runtime: either an enum, or an
as constarray or object. A union type alone cannot give you one.
const enum: Skip It
A const enum asks the compiler to inline the values instead of emitting an object, so Size.Medium becomes the literal "M" in the output and nothing else is generated. On paper that is strictly better: smaller output, no runtime cost.
In practice it causes build problems, and the reason is worth understanding. Inlining requires the compiler to know the enum's definition while it compiles the file that uses it. Modern build tools — esbuild, Babel, and therefore Vite and most bundler setups — deliberately compile each file in isolation for speed, so they cannot look into another file to find the values. The TypeScript documentation lists these pitfalls itself, and this is why const enum is discouraged in application code and outright banned by many style guides.
Unless you are compiling with tsc alone and have measured a real problem, use a normal enum or the as const pattern below. The saving is tiny; the class of bug is not.
const enum Size {
Small = "S",
Medium = "M",
Large = "L"
}
const shirt = Size.Medium;
// tsc emits: const shirt = "M"; — the enum object is never created
// The catch: there is no Size object at runtime, so this is impossible
// Object.values(Size); <- there is nothing to read
// And a file-by-file transpiler compiling the line above has no way
// to know that Size.Medium means "M", which is where builds break. - The related compiler flag
isolatedModulesexists to warn you about exactly this family of problem: it restricts you to code that can be compiled one file at a time, which is what fast bundlers require.
The Modern Alternative: as const Plus a Union
You can get everything an enum gives you using two features you have already met: a plain object frozen with as const, and a union type derived from it. The object gives you runtime values to iterate over; the derived type gives you compile-time checking.
The line type Role = typeof ROLES[keyof typeof ROLES] looks cryptic, so read it inside out. typeof ROLES is the type of the object. keyof typeof ROLES is the union of its keys. Indexing the object type by that union gives the union of its values — "admin" | "teacher" | "student". It is worth writing out once by hand until it stops looking like magic; the same pattern appears throughout real TypeScript codebases.
The practical differences are small but real. The union version accepts plain strings, which is convenient at API boundaries and slightly looser inside your code. It has no reverse mapping and no numeric surprises. It disappears from the type system while leaving a single small object behind, and bundlers understand that object perfectly. Enums, in exchange, give you the stricter nominal behaviour and a slightly tidier syntax.
Either choice is defensible. What is not defensible is mixing three approaches in one codebase, so pick one and write it down for your team.
const ROLES = {
Admin: "admin",
Teacher: "teacher",
Student: "student"
} as const;
type Role = typeof ROLES[keyof typeof ROLES];
// "admin" | "teacher" | "student"
function grantAccess(role: Role) { /* ... */ }
grantAccess(ROLES.Admin); // OK
grantAccess("teacher"); // OK — plain strings are accepted here
grantAccess("principal"); // ERROR: not assignable to type 'Role'
// Runtime list, exactly like Object.values on an enum
Object.values(ROLES).forEach(r => console.log(r));
// The simplest version, when you do not need named keys
const STATUSES = ["placed", "packed", "shipped"] as const;
type Status = typeof STATUSES[number]; - Rule of thumb: choose
enumwhen you want the stricter nominal checking and the values are internal to your system; chooseas constwhen the values cross a boundary as plain strings, or when your build tooling is doing the compiling. Avoidconst enumeither way.
