What you'll learn
Quick Answer
A string literal union such as "active" | "expired" gives the same autocomplete, the same compile-time safety and the same narrowing as an enum, while emitting no JavaScript at all. Enums compile to a real runtime object, cannot accept plain string values without a cast, and in the numeric form silently renumber members when you insert one. Use a union with an as const array when you need the values at runtime, and reach for an enum only when a framework or code generator requires it.
An enum is not just a type, it is an object
Interfaces, type aliases, generics and utility types all vanish during compilation. The emitted JavaScript looks like the code you wrote with the annotations deleted. Enums are one of the very few constructs that break this rule and produce actual runtime code.
enum Status {
Active = "active",
Expired = "expired",
}compiles to roughly:
var Status;
(function (Status) {
Status["Active"] = "active";
Status["Expired"] = "expired";
})(Status || (Status = {}));That object ships in your bundle. For a numeric enum it is worse, because TypeScript also builds a reverse mapping so Status[0] gives you back "Active", which roughly doubles the size of the generated object. And because the whole thing is wrapped in an immediately invoked function that mutates a variable, bundlers have a harder time proving it is unused and dropping it than they would with a plain object literal.
The consequence people notice first is different, though. An enum member is not interchangeable with its own value:
function setStatus(s: Status) { /* ... */ }
setStatus(Status.Active); // fine
setStatus("active"); // Error: '"active"' is not assignable to type 'Status'At runtime Status.Active === "active" is true. The compiler treats the enum as its own distinct type anyway. Some teams want exactly that, because it stops an arbitrary string being passed where a status is expected. In practice it means that every value arriving from an API response, a form field, a URL parameter or a database row has to be converted with value as Status. That assertion performs no check whatsoever, so the nominal safety you were paying for is bypassed at precisely the boundary where a wrong value could enter. You end up with the ceremony and not the protection.
String literal unions: same safety, nothing emitted
A union of string literals does the same job with no runtime footprint at all.
type Status = "active" | "expired" | "trial";
function setStatus(s: Status) { /* ... */ }
setStatus("active"); // fine
setStatus("activee"); // Error: not assignable to type 'Status'Autocomplete works identically. Type the opening quote inside the call and the editor offers all three values. Typos are caught. Renaming a member with the editor's rename refactor updates every usage. And the compiled output contains a bare string, because the type was erased.
Narrowing works the same way too, which is what makes unions pleasant to use in real logic:
function label(s: Status): string {
switch (s) {
case "active": return "Active";
case "expired": return "Expired";
case "trial": return "Trial";
default: {
const exhaustive: never = s;
throw new Error(`Unhandled status: ${exhaustive}`);
}
}
}Add a fourth status to the type and this function stops compiling, pointing at the exact line that needs updating. That is the same exhaustiveness guarantee an enum gives you, from a type that costs nothing.
Data from an API also flows in without friction. If you validate a response once at the boundary and confirm the field is one of the three strings, it is already a Status and needs no conversion anywhere downstream. With an enum, the same value needs an assertion at every entry point.
There is exactly one thing a union cannot do: you cannot iterate it. A union type does not exist at runtime, so there is no list of members to loop over for a dropdown or a validation check. That is the real trade-off, and the next section is how you get it back.
as const: getting the values back at runtime
When you need both the type and a runtime list, declare the list and derive the type from it. Do not declare them separately, or they will drift apart.
const STATUSES = ["active", "expired", "trial"] as const;
type Status = (typeof STATUSES)[number];
// "active" | "expired" | "trial"
STATUSES.forEach((s) => console.log(s)); // real array, real loopas const is doing two things. It makes the array and its members readonly, and it stops TypeScript widening the literals to string. Without it, STATUSES would be string[] and the derived type would just be string, which checks nothing. The [number] at the end is an indexed access: the type you get when you index the array with any number, which is the union of its element types.
The object form is the one you want for anything with an associated value, such as prices or labels:
const PLAN_PRICE = {
free: 0,
pro: 199,
campus: 4999,
} as const;
type Plan = keyof typeof PLAN_PRICE; // "free" | "pro" | "campus"
function priceOf(p: Plan): number {
return PLAN_PRICE[p];
}One object is now the single source of truth for both the valid keys and the amounts in rupees. Add a plan and the type updates automatically, and every Record<Plan, ...> elsewhere in the codebase immediately reports a missing entry.
Two things to expect. First, as const arrays are readonly, so passing one to a function that takes string[] is an error. Declare such parameters as readonly string[] and the problem disappears. Second, runtime validation needs a small widening cast, because includes on a readonly tuple of literals refuses an arbitrary string argument:
function isStatus(v: string): v is Status {
return (STATUSES as readonly string[]).includes(v);
}
const raw = new URLSearchParams(location.search).get("status") ?? "";
if (isStatus(raw)) {
setStatus(raw); // raw is Status here
}That is the complete pattern: one declaration, a derived type, a runtime list, and a guard that turns untrusted input into the union safely.
const enum and the transpile-only trap
const enum looks like the answer to the bundle-size objection. The compiler inlines the values at each usage site and emits no object at all:
const enum Direction { Up = 1, Down = 2 }
const d = Direction.Up;
// emitted: const d = 1;The problem is that inlining requires whole-program knowledge. To replace Direction.Up with 1, the compiler must have read the file where Direction was declared. That is fine when tsc itself is producing the output. It fails when your build tool transpiles one file at a time, which is how esbuild, swc and Babel work, and therefore how Vite and several other modern toolchains work by default. Those tools see Direction.Up in a file that only imports the name and have no idea what to inline.
The isolatedModules compiler flag is the guardrail here: it reports the const enum accesses a single-file transpiler cannot support, notably reading a member from an ambient declare const enum in a dependency. It is not a complete safety net for every cross-file case, which is another reason to avoid the construct rather than configure around it. Without it, behaviour varies by tool: Babel's TypeScript plugin historically treated const enum as a regular enum, while other pipelines can produce a reference to a binding that does not exist at runtime.
There is a second, sharper version of this. A library that ships const enum declarations in its published .d.ts files forces every consumer into the same constraint, and consumers using isolated transpilation simply cannot use those members. Several packages have had to remove const enum from their public API for exactly this reason.
The rules are therefore narrow. Never put a const enum in the public surface of a package. Inside an application, use one only if tsc is doing the emit and you have measured that it matters. In every other case, a plain const object with as const gives you the same ergonomics with no build-tool coupling at all, and a bundler can tree-shake a plain object more reliably than it can an enum's IIFE.
When an enum is fine, and how to migrate off one
There are legitimate cases. Code generators decide for you: GraphQL Code Generator emits TypeScript enums by default, and several ORM and framework toolchains do the same and expect those enum values in their APIs, so fighting the generator costs more than it saves. Check what yours actually emits before assuming, since some have moved to the const-object pattern below. A large existing codebase that already uses enums consistently is also a reasonable place to keep using them, because a half-migrated codebase where the same concept is an enum in one module and a union in another is worse than either style applied uniformly.
What to avoid in almost all cases is the numeric enum, and the reason is a genuine data hazard.
enum Level { Beginner, Intermediate, Advanced }
// Beginner = 0, Intermediate = 1, Advanced = 2Six months later somebody adds a foundation tier at the top:
enum Level { Foundation, Beginner, Intermediate, Advanced }
// Foundation = 0, Beginner = 1, Intermediate = 2, Advanced = 3Every member after the insertion point silently changed value. If those numbers were written to a database, sent to another service or stored in a browser's local storage, every existing row now means something one level off. Nothing fails to compile. Nothing throws. Reports are simply wrong. Numeric enums also produce that reverse mapping, so Object.values(Level) returns both the names and the numbers, which breaks the obvious way of building a dropdown.
If you decide to move away from enums, this is the drop-in replacement, and it keeps every existing call site working:
export const Status = {
Active: "active",
Expired: "expired",
Trial: "trial",
} as const;
export type Status = (typeof Status)[keyof typeof Status];
// "active" | "expired" | "trial"Declaring a constant and a type with the same name is legal because values and types live in separate declaration spaces, which is also how classes work. Existing code that writes Status.Active and annotates with : Status continues to compile unchanged. What you gain is that plain strings now assign without a cast, the values are iterable through Object.values(Status) with no reverse-mapping surprise, and the emitted output is one small object literal a bundler can reason about.
