The pizza application has 108 TypeScript files across two frontends, models five different sets of
fixed values, and contains not a single enum. That is deliberate, and the reasons are
worth understanding — enum is the one construct in this language that behaves unlike
everything else in it.
What an enum is
enum OrderStatus {
PendingPayment,
Paid,
Preparing,
Completed,
Cancelled,
}
const status = OrderStatus.Paid; // 1By default the members are numbered from zero. You can assign strings instead, which is what anyone modelling an API does:
enum OrderStatus {
PendingPayment = 'PENDING_PAYMENT',
Paid = 'PAID',
Preparing = 'PREPARING',
Completed = 'COMPLETED',
Cancelled = 'CANCELLED',
}It looks tidy, it autocompletes, and it groups the values under a name. So what is the objection?
It is the one feature that emits JavaScript
Everything else in TypeScript disappears at build time. An enum does not — it
compiles into a real object that exists at runtime:
var OrderStatus;
(function (OrderStatus) {
OrderStatus["PendingPayment"] = "PENDING_PAYMENT";
OrderStatus["Paid"] = "PAID";
OrderStatus["Preparing"] = "PREPARING";
OrderStatus["Completed"] = "COMPLETED";
OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));That is a violation of the model set out in lesson 1: types are erased, and here a type declaration has generated code. Every awkward thing about enums follows from it.
The numeric reverse mapping
A numeric enum emits twice as many keys as you wrote, because it builds a reverse map so you can go from value back to name:
OrderStatus[0] = "PendingPayment";
OrderStatus["PendingPayment"] = 0;Which means Object.keys(OrderStatus) gives you ten entries for a five-member enum,
and iterating one requires filtering out the numbers. Anybody who has written a dropdown from a
numeric enum has met this.
Worse, a numeric enum accepts any number at all in some positions, which is not what a fixed set of values ought to do. String enums do not have either problem — which is a reason to prefer them, and also an early sign that the feature has more corners than it should.
const enum does not work in modern builds
The usual answer to the emitted-object problem is const enum, which inlines the
values and emits nothing:
const enum Size { Small = 'SMALL' }
const s = Size.Small; // compiles to: const s = "SMALL";Except that inlining requires the compiler to see the declaration and the use site together, and tools that compile one file at a time cannot. That is Babel, esbuild, SWC and Vite — which is to say, most of what builds a frontend today.
Hence isolatedModules, which both pizza configs effectively require, and which makes
exporting a const enum across a module boundary an error. The feature is not usable in
the setup most projects have.
And some builds reject enum outright
The React half of the pizza app compiles with this:
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": trueerasableSyntaxOnly bans every TypeScript construct that has to emit
something to work. That is enum, parameter properties, namespaces with runtime members,
and decorators.
The flag exists because of the direction the tooling has moved. Node can now run
.ts files directly by stripping types — and stripping is all it does, so anything
requiring real compilation cannot work. Turning the flag on is how a project guarantees its source
stays runnable by a type-stripper.
So in that project the question is not stylistic. enum does not compile.
What to use instead
For a set of values that come from an API, a union of string literals:
export type OrderStatus =
| 'PENDING_PAYMENT'
| 'PAID'
| 'PREPARING'
| 'COMPLETED'
| 'CANCELLED';Look at what this gives you that the enum did not. The values are the API's
strings, so no conversion is needed in either direction —
JSON.parse output is already of the right type, and no mapping table has to be
maintained. Nothing is emitted. Autocomplete works. And an invalid value is a compile error, which
was the whole point.
Assignment is direct, with no import of the enum object:
const status: OrderStatus = 'PAID';Compare with OrderStatus.Paid, which requires a value import and gives you a name
that is not the thing on the wire.
When you need the values at runtime
The one genuine advantage of an enum is that it exists as an object, so you can iterate it. A union cannot be iterated — it is erased.
The replacement is a const array or object, with the type
derived from it so the two can never disagree. The app does exactly this for its report
date ranges:
const RANGES = [7, 30, 90] as const;Without as const that is number[] and nothing is gained. With it, the
type is readonly [7, 30, 90] — the exact values, in order, frozen — so
RANGES[number] is the union 7 | 30 | 90 and the array can be mapped over
to render the buttons.
The same pattern for a named set:
export const ORDER_STATUS = {
PENDING_PAYMENT: 'PENDING_PAYMENT',
PAID: 'PAID',
PREPARING: 'PREPARING',
COMPLETED: 'COMPLETED',
CANCELLED: 'CANCELLED',
} as const;
export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];That second line reads as "the type of any value in that object", and it resolves to exactly the union written by hand earlier. Add a key and the union grows by itself, which is the entire reason to derive it rather than write it twice. Lesson 15 takes that expression apart piece by piece.
You get an ordinary object you can iterate, a union that cannot drift from it, plain strings that match the database, and — unlike an enum — nothing generated that you did not write.
Rendering a dropdown from each
The concrete version of the iteration question, since it is what people actually hit. With an enum you iterate the object and filter out the reverse mappings:
// numeric enum — the values are the strings, the keys are the numbers, or vice versa
{Object.values(OrderStatus)
.filter((v) => typeof v === 'string')
.map((v) => <option key={v}>{v}</option>)}With a const array there is nothing to filter, and the type comes out right:
const ORDER_STATUSES = [
'PENDING_PAYMENT', 'PAID', 'PREPARING', 'COMPLETED', 'CANCELLED',
] as const;
type OrderStatus = (typeof ORDER_STATUSES)[number];
{ORDER_STATUSES.map((s) => <option key={s}>{s}</option>)}(typeof ORDER_STATUSES)[number] is "the type of any element of that array", which
is the union again. One declaration, a list you can map over, and a type derived from it.
Order is also preserved and meaningful, which matters for a dropdown and which an enum object does not promise.
If you already have enums
There is no urgency. An existing string enum is not a bug, and a migration for its own sake is churn. If you do move, the steps that keep the build green throughout:
- Make sure it is a string enum first, with the values matching the wire format. Numeric enums have to be dealt with before anything else, because their values are not the data.
- Add the union type beside it:
type OrderStatus = 'PAID' | …. At this pointOrderStatus.Paidand'PAID'are mutually assignable, so both work. - Replace
OrderStatus.Paidwith'PAID'a file at a time. - Delete the enum. Anything still using it fails the build, which is the list of what is left.
Step 2 is what makes this safe — a string enum member is assignable to its literal type, so the two representations coexist for as long as you need.
If you are coming from Java or C#
Worth saying plainly, because the word is the same and the thing is not.
A Java enum is a class. Its members are singleton instances, they can carry fields and methods,
they are nominally typed, and values() is part of the language. Switching over one is
checked for exhaustiveness by the compiler.
A TypeScript enum is a plain object with some compile-time checking layered on. It has no methods, no fields beyond its value, and — because the language is structural — a string enum member is interchangeable with its literal string. That last point surprises people most: the type is not sealed the way a nominal enum is.
So the instinct "a fixed set of values means an enum" transfers, but the construct that best serves it here is the union. You keep exhaustiveness checking — see the next lesson — and lose nothing you were relying on.
Side by side
enum | literal union | as const object | |
|---|---|---|---|
| emits runtime code | yes | no | yes — but it is code you wrote |
| iterable at runtime | yes | no | yes |
| value equals the API string | only if you assign it | always | always |
works under isolatedModules | not as const enum | yes | yes |
works under erasableSyntaxOnly | no | yes | yes |
The one place you cannot avoid them
A dependency may export an enum, and then you have to use it as a value:
import { LogLevel } from 'some-library';
configure({ level: LogLevel.Debug });That import cannot be import type, because LogLevel.Debug is a property
access on a real runtime object. It is one of the clearest illustrations of what makes enums
different from every other type construct — and a small argument against exporting one from a
library you publish, since you are making that decision for every consumer.
So is enum always wrong?
No, and it is worth being fair about it. If you are writing a Node service compiled by
tsc itself, with no bundler and no erasableSyntaxOnly, a string enum is
perfectly serviceable and the grouping under a namespace is genuinely pleasant.
Angular projects use them without difficulty for the same reason — the Angular compiler handles whole programs, so none of the isolated-module problems arise.
But the default has moved, and the reason is not fashion. The two alternatives above do everything an enum does, work in every build setup, and produce exactly the JavaScript you wrote. When a feature's replacement is strictly simpler, the feature stops being the obvious choice.
TypeScript's own team has been fairly direct about this: if they were designing the language today, enums would probably not be in it.
The practical test, if you want one: does anything need to enumerate these values at
runtime? If not, use the union — it is less code and less output. If so, use a
const object or array and derive the union from it. The enum sits awkwardly between
the two, doing both jobs at the cost of being unlike everything else in the language.
Next
Narrowing and Type Guards — how to get from a union back to a single type, which is what makes all of this usable.