TypeScript – Enums, and What to Use Instead

July 31, 20268 min readUpdated 9/5/2026

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;   // 1

By 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": true

erasableSyntaxOnly 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:

  1. 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.
  2. Add the union type beside it: type OrderStatus = 'PAID' | …. At this point OrderStatus.Paid and 'PAID' are mutually assignable, so both work.
  3. Replace OrderStatus.Paid with 'PAID' a file at a time.
  4. 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

enumliteral unionas const object
emits runtime codeyesnoyes — but it is code you wrote
iterable at runtimeyesnoyes
value equals the API stringonly if you assign italwaysalways
works under isolatedModulesnot as const enumyesyes
works under erasableSyntaxOnlynoyesyes

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.