RizTech Academy logo
RizTech Academy
The Basic TypesLesson 3 of 620 min

Enums, and why a union of literals is usually better

An enum lets you name a fixed set of related constants — the states of an order, the days of the week, the roles a user can have. TypeScript has real enums, and you will meet them in code. But this lesson makes an argument that surprises newcomers: most of the time, a union of string literals is the better tool, and you should reach for enums rarely. Understanding both, and why, is the point.

The enum

enum OrderStatus {
  Pending,
  Paid,
  Shipped,
  Delivered,
}

let status: OrderStatus = OrderStatus.Paid;

OrderStatus is a named set of four values. You refer to them as OrderStatus.Pending, OrderStatus.Paid, and so on, and a variable typed OrderStatus can only hold one of the four. That is genuinely useful — it stops you assigning a random string and mistyping a status.

By default, enum members are numbers: Pending is 0, Paid is 1, Shipped is 2, Delivered is 3. That numbering is the source of the first surprise.

The problems with (numeric) enums

They are just numbers underneath, which leaks out.

enum OrderStatus { Pending, Paid, Shipped, Delivered }
console.log(OrderStatus.Paid);         // 1  — not "Paid"! it logs the number
const s: OrderStatus = 99;             // no error! any number is accepted

Logging a status shows 1, not Paid, which is useless in a debugger or a log file. Worse, because the underlying type is number, TypeScript accepts any number as an OrderStatus — 99 is happily assigned, defeating the whole point of a fixed set. That is a real hole.

They exist at runtime, unlike everything else in TypeScript. Enums are one of the very few TypeScript features that are not erased — they compile to actual JavaScript objects. This means they add code to your bundle, and they behave differently from types (which vanish). For a feature meant to be a simple named constant, that is surprising and occasionally inconvenient.

String enums fix the logging problem but not the runtime one:

enum OrderStatus {
  Pending = "PENDING",
  Paid = "PAID",
  Shipped = "SHIPPED",
  Delivered = "DELIVERED",
}
console.log(OrderStatus.Paid);         // "PAID"  — better

String enums log readably and do not accept arbitrary values, so they are strictly better than numeric enums. But they still generate runtime code and still have the ceremony of the enum keyword.

The better default: a union of string literals

Here is the pattern most experienced TypeScript developers reach for instead, and it is beautifully simple:

type OrderStatus = "pending" | "paid" | "shipped" | "delivered";

let status: OrderStatus = "paid";      // fine
status = "pade";                       // error: Type '"pade"' is not assignable to type 'OrderStatus'

OrderStatus is now just a type: a value must be exactly one of those four strings. Look at what you get:

  • No runtime code at all. This is a type — it is erased like everything else, adding nothing to your bundle.
  • It is the value. status is literally the string "paid", so it logs as "paid", serialises to JSON as "paid", and compares with === naturally. No OrderStatus.Paid indirection.
  • A typo is a compile error. "pade" is rejected, exactly as an enum would reject it — you get the safety without the machinery.
  • It works perfectly with narrowing and exhaustiveness (the unions module), so a switch over the statuses can be checked for completeness by the compiler.

Using it reads naturally everywhere:

function describe(status: OrderStatus): string {
  if (status === "delivered") return "Your order arrived";
  return `Order is ${status}`;
}
describe("shipped");     // fine — just pass the string

You pass plain strings, TypeScript checks they are valid members, and there is no import of an enum object. For the overwhelming majority of "a fixed set of named options", this is the tool to reach for.

When an enum still earns its place

Not never — be fair to the feature:

  • When you genuinely need the runtime object — to iterate over all the values, or map between a name and a number, at runtime. A union of literals is a type and does not exist at runtime, so if you need to loop over the members, an enum (or a const array of the literals) gives you something real to loop over.
  • When a team's codebase already uses enums consistently — consistency has value; do not introduce a second style for its own sake.
  • const enum — a variant that is erased (inlined at compile time) and so has no runtime cost, for when you want enum syntax without the object. It has its own caveats (it does not play well with some build setups), so know it exists but reach for the union first.

The rule of thumb

Default to a union of string literals. Reach for an enum only when you specifically need a runtime object to iterate or map over. This flips the instinct many bring from other languages, where enums are the obvious choice — in TypeScript, the lighter, erased, "it is just the value" union is usually better, and it is what most modern TypeScript code uses. When you see "pending" | "paid" | ... in a real codebase, now you know why.

Check your work

What an enum is. A named, fixed set of related constants.

The default underlying type of a numeric enum, and its two problems. number — it logs as a number, and it accepts any number, defeating the fixed set.

How enums differ from most TypeScript features. They are not erased; they generate runtime JavaScript objects.

What string enums fix and do not fix. They log readably and reject bad values, but still generate runtime code and use the enum ceremony.

The better default. A union of string literals — "pending" | "paid" | ....

Four advantages of the literal union. No runtime code (erased), the value is the string, typos are compile errors, and it works with narrowing and exhaustiveness.

When an enum still earns its place. When you need a real runtime object to iterate or map over, or for consistency in a codebase that already uses them; const enum for an erased variant.

The rule of thumb. Default to a literal union; use an enum only when you need a runtime object.

Practice

  1. Write a numeric enum and console.log a member. Observe it prints a number.
  2. Assign 99 to that enum-typed variable and confirm TypeScript accepts it. Explain why that is bad.
  3. Rewrite it as a string enum and confirm logging is now readable.
  4. Rewrite it again as a union of string literals. Assign a valid value and a typo; read the error.
  5. Compile all three versions and compare the emitted JavaScript. Note the union produces no runtime code.
  6. Write a function taking the literal-union type and pass a plain string to it.
  7. Describe one situation where you would genuinely want an enum's runtime object, and how you would iterate a literal union's members instead (hint: a const array of the literals).

Official documentation

Next: any, unknown and never — the escape hatch, its safe cousin, and the impossible type.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship