skip to content

Enum Pitfalls & Union-of-Literals Alternative

Why numeric enums are unsound, why enums fight tree-shaking and type-only compilation, and how a union of string literals or an `as const` object replaces them with zero runtime footprint. A standard senior-level question about choosing constructs, not just using them.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In TypeScript, what does an `enum` declaration emit into the compiled JavaScript that a union of string literals does not, and why does that difference matter?

level: middleimportance: must knowfreq 62%

answer

  1. types are erased — with exceptions
  2. one construct leaves an object behind
  3. self-invoking function, not just data
  4. bundler has to prove it droppable
  5. union emits literally nothing

basics

~20 s

An enum is one of the few TypeScript constructs that emits runtime code: a real object built by a self-invoking function. A union of string literals emits nothing, so it costs no bytes and works in tools that only strip types.

solid answer

~50 s

TypeScript's core rule is that types are erased, and `enum` is one of the handful of exceptions. For `enum Status { Active = 'active' }` the compiler emits `var Status; (function (Status) { Status["Active"] = "active"; })(Status || (Status = {}));` — a real object that lands in the bundle whether or not anything reads it at runtime. A `type Status = 'active' | 'archived'` emits nothing at all; after compilation the value is just a string. Three consequences follow. The enum adds bytes and hands the bundler an assignment it must prove side-effect-free before dropping, so unused enums often survive tree-shaking. The enum is a value as well as a type, so it cannot be brought in with `import type` and then used to produce a member. And it is not erasable syntax: with the compiler's `erasableSyntaxOnly` flag on, or in a runtime that only strips types, an `enum` declaration is an error.

code

typescript · 8 lines
typescript
export enum Status { Active = 'active', Archived = 'archived' }
export type Mode = 'light' | 'dark';
export const Colour = { Red: 'red', Blue: 'blue' } as const;
export type Colour = (typeof Colour)[keyof typeof Colour];

const m: Mode = 'light';
const col: Colour = 'red';
console.log(m, col, Status.Active, Object.values(Colour));

go deeper

for a junior

Know that TypeScript types normally vanish at compile time and that enum is one of the exceptions: it leaves a real object behind. Be able to say plainly that a string-literal union leaves nothing at all.

for a middle

Be ready to write the emitted self-invoking function from memory and point at the doubled assignment that builds the reverse map for numeric enums. Explain why the enum is a value as well as a type.

for a senior

Show you have reasoned about the output, not just the source: when an unused enum survives tree-shaking, and how you would pick between an enum and an as const object in a package that ships to browsers.

for a principal

Own the build-constraint angle. If the organisation wants type-stripping runtimes or erasableSyntaxOnly builds, enums stop being a style preference and become a portability decision for every package you publish.

## The rule this question hangs on TypeScript is a type layer over JavaScript. Almost everything you write in that layer — annotations, interfaces, type aliases, generics — is deleted during compilation and has no runtime existence, no runtime cost, and no way to be inspected while the program runs. `enum` is one of the few constructs that breaks that rule and generates real JavaScript. Understanding exactly what it generates is what separates "I use enums" from "I chose an enum". ## What an enum compiles to A string enum: ```ts export enum Status { Active = 'active', Archived = 'archived' } ``` emits: ```js export var Status; (function (Status) { Status["Active"] = "active"; Status["Archived"] = "archived"; })(Status || (Status = {})); ``` A numeric enum emits the same shape with a doubled assignment: ```js var Direction; (function (Direction) { Direction[Direction["Up"] = 0] = "Up"; Direction[Direction["Down"] = 1] = "Down"; })(Direction || (Direction = {})); ``` The inner assignment stores name-to-value and the outer one stores value-to-name, which is why a numeric enum object carries both directions and why `Object.values(Direction)` is `["Up", "Down", 0, 1]` rather than the two numbers you might expect. The `var` plus self-invoking function shape exists so that separate declarations of the same enum can extend one object. It is ordinary code: a declaration, a function call, and property writes. ## What the alternatives compile to A union of string literals: ```ts type Mode = 'light' | 'dark'; const m: Mode = 'light'; ``` emits exactly `const m = 'light';`. The type contributed nothing to the output — there is no `Mode` at runtime, and no way to ask the program which values `Mode` allows. An `as const` object sits in between: ```ts export const Colour = { Red: 'red', Blue: 'blue' } as const; export type Colour = (typeof Colour)[keyof typeof Colour]; ``` emits `export const Colour = { Red: 'red', Blue: 'blue' };` — a plain data literal. The assertion and the derived type are erased. You still have a runtime object you can iterate, but it is a normal `const` with no wrapper function and no reverse-mapping entries. ## Why the emit matters in practice **Bundle weight.** Each enum is a small but non-zero block of code plus, for numeric enums, twice the property writes. Dozens of them across a shared package add up in a browser bundle where a union would have added nothing. **Tree-shaking.** Bundlers drop unused exports only when they can prove that evaluating them has no side effects. A plain `const` object literal is easy to prove pure; a `var` mutated by an invoked function expression is harder, and whether it is removed depends on the specific bundler's analysis and settings. Some toolchains special-case the enum pattern; others keep it. "It will be tree-shaken anyway" is an assumption, not a guarantee. **Import kind.** Because the enum is a value, code that produces `Status.Active` needs a real runtime import. `import type { Status }` gives you only the type; using it as a value from that import is an error. A union has no value side to import at all, so it is always a type-only dependency. **Erasable syntax.** Modern pipelines increasingly compile TypeScript by *deleting* types rather than transforming code — that is what the compiler's `erasableSyntaxOnly` flag models, and what type-stripping runtimes do. Under that flag, `enum Colour { Red }` is rejected with "This syntax is not allowed when 'erasableSyntaxOnly' is enabled", because there is no way to strip it: the declaration has runtime meaning. Unions and `as const` objects pass, because one disappears entirely and the other is already valid JavaScript. ```ts // rejected under erasableSyntaxOnly enum Colour { Red, Blue } // both fine type Colour2 = 'red' | 'blue'; const Colour3 = { Red: 'red', Blue: 'blue' } as const; ``` (The `const enum` form is TypeScript's own answer to the emit cost and comes with its own separate caveats around cross-file compilation; it is a different construct from a plain `enum`, not a free win.) ## How to choose Ask what you need at runtime. If the set is only ever a compile-time constraint on values that already exist as strings — status fields, variants, option names — a union of string literals is the cheapest correct answer. If you need to enumerate, validate, or map the members while the program runs, you need *some* object; make it an `as const` object so the runtime part is plain data you can see. Reach for `enum` when you specifically want the declaration's nominal behaviour and your build has no erasability constraint. ## Saying it in an interview Lead with the rule ("types are erased; enum is an exception"), show the emitted object, then name the three consequences: bytes, tree-shaking analysis, and erasability. That ordering shows you reason from the compilation model rather than from a style guide.

  • If the enum object is emitted anyway, why can't I just rely on the bundler to remove the ones nobody uses?
    Because removal depends on the bundler proving the emitted code has no side effects. An enum compiles to a `var` mutated inside an invoked function expression, which some toolchains recognise as safe to drop and others do not. A plain `const` object literal or a union gives you the same outcome without depending on that analysis.
  • What breaks if a teammate changes `import { Status }` to `import type { Status }` for an enum?
    Type positions keep working, but any expression that produces a member — `Status.Active`, `Object.values(Status)` — fails to compile, because a type-only import erases the value binding. It is a good demonstration that an enum is two things at once: a type and a runtime value.
  • Does an `as const` object give you back everything an enum offered?
    Not everything. You get the runtime object, the derived union type, and the same `Colour.Red` call sites, with cheaper emit. You lose the enum's nominal behaviour — a bare `'red'` string is now assignable — and you lose auto-numbering and reverse mapping, which you have to write yourself if you want them.

saying these in an interview costs you the question

  • Says enums are erased at compile time like every other type
  • Assumes bundlers always tree-shake an unused enum away
  • Thinks a string-literal union has a runtime value you can iterate
  • Believes import type works for an enum you then use as a value
  • Treats enum versus union as purely a style preference

context

open as a page

Given `enum Priority { Low = 1, High = 2 }` in TypeScript, does `const p: Priority = value` compile when `value` is typed `number`, and what does the answer mean for enum-typed data that arrived as JSON?

level: seniorimportance: must knowfreq 48%

basics

~20 s

Yes, it compiles. TypeScript still lets any value of type number flow into a numeric enum type, so a number parsed from JSON is accepted unchecked. Only an out-of-range numeric literal is rejected, so validate at the boundary.

open as a page

In TypeScript, `enum Status { Active = 'active' }` rejects `const s: Status = 'active'`, while `type Status = 'active' | 'archived'` accepts it. What rule explains the difference, and why does comparing members of two different enums raise an error?

level: middleimportance: should knowfreq 52%

basics

~20 s

An enum declaration creates its own distinct type whose members are identified by where they were declared, not by their value, so a matching string literal is not assignable to it. A literal union is structural: any value of the right shape belongs.

open as a page

You need a closed set of order states in TypeScript that is checked at compile time and can also be listed at runtime, without declaring an enum. How do you model it, and what do you give up?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Declare a frozen-shape object with as const for the runtime side, then derive the type from it with an indexed access over its own keys. You get the same member-style call sites and an iterable object, and you lose the enum's nominal behaviour.

open as a page

Your TypeScript monorepo models closed value sets inconsistently — some packages use enums, others string-literal unions. How would you decide on a standard, and what would you do about numeric enums whose values are already stored in the database and sent on the wire?

level: principalimportance: should knowfreq 30%

basics

~20 s

Decide from constraints, not taste: does the value cross a serialization boundary, is it needed at runtime, and does the build require erasable syntax. Then change the type layer while freezing the stored values, and migrate on touch rather than all at once.

open as a page