Skip to content
TR

Phase 3 · Classes, enums and modules · Lesson 3.2

Intermediate

Enums vs as const

Numeric, string and const enums, why enums aren't erasable syntax, and the as-const object and string-union patterns most teams now prefer.

20 min

You need a fixed set of named values: roles, statuses, directions. TypeScript offers a dedicated feature for that, enum, and it's one of the very few features that generates JavaScript instead of being erased. That single fact explains most of the debate about whether to use enums at all, and "enums or unions?" is a favourite interview question.

Numeric enums

Without initializers, members are numbered from 0, and each member without an initializer is the previous one plus one.

enum Direction {
  Up,        // 0
  Down,      // 1
  Left = 10,
  Right,     // 11
}
 
const d = Direction.Right;
//    ^? const d: Direction.Right   (value 11)
 
function move(dir: Direction) {}
move(Direction.Up);

The enum is both a type and a value. Direction the type is the union of its members; Direction the value is a real object at runtime. Here's what tsc emits:

var Direction;
(function (Direction) {
  Direction[(Direction["Up"] = 0)] = "Up";
  Direction[(Direction["Down"] = 1)] = "Down";
  Direction[(Direction["Left"] = 10)] = "Left";
  Direction[(Direction["Right"] = 11)] = "Right";
})(Direction || (Direction = {}));

Reverse mapping

Each numeric member is written twice: name → number and number → name. So Direction[0] is "Up". Handy for logging, but it has a well-known side effect:

enum Level {
  Low = 1,
  High,
}
 
Level[1];                   // "Low"
console.log(Object.keys(Level));
// ["1", "2", "Low", "High"]  (integer-like keys first, then the names)

Iterating a numeric enum gives you the numbers and the names. Filter with isNaN(Number(key)) if you only want names. String enums don't have this problem because they have no reverse mapping.

Numeric enums and plain numbers

Since TypeScript 5.0, all enums are union enums, so assigning a literal number that isn't a member is an error. But a value typed as plain number is still accepted: numeric enums are loosely checked against number.

enum Status {
  Draft,
  Published,
}
 
const a: Status = 1;       // fine: 1 is Status.Published
// @ts-expect-error -- Type '99' is not assignable to type 'Status'.
const b: Status = 99;
 
const fromApi: number = 99;
const c: Status = fromApi; // compiles! no check against the members

That last line is the hole: data from outside can smuggle any number into a Status.

String enums

Each member gets an explicit string. No auto-increment, no reverse mapping, and readable values at runtime.

enum Role {
  Admin = "ADMIN",
  Editor = "EDITOR",
  Viewer = "VIEWER",
}
 
function canPublish(role: Role) {
  return role === Role.Admin || role === Role.Editor;
}
 
canPublish(Role.Admin);
// @ts-expect-error -- Argument of type '"ADMIN"' is not assignable to parameter of type 'Role'.
canPublish("ADMIN");
 
const asText: string = Role.Admin; // fine the other way round

This is the big difference from almost everything else in TypeScript: string enums are nominal. The literal "ADMIN" has exactly the right value, yet it is not assignable to Role. Callers must import the enum and write Role.Admin. Some teams like that (a single source of truth); others find it gets in the way, especially for values arriving from JSON.

Quick check

What does Object.keys(Color) return?

enum Color {
  Red,
  Green,
  Blue,
}

const enums

A const enum is erased entirely. Every use is inlined as its literal value and no object is emitted:

const enum Size {
  Small = "S",
  Large = "L",
}
 
const s = Size.Large; // emitted as: const s = "L" /* Size.Large */;

That sounds like the best of both worlds, but it relies on the compiler seeing the enum's definition while compiling the file that uses it. Tools that compile one file at a time (esbuild, SWC, Babel, Node's type stripping) can't do that. That is what isolatedModules protects against:

  • With isolatedModules (implied by verbatimModuleSyntax), referencing an ambient const enum from a .d.ts is an error: Cannot access ambient const enums when 'isolatedModules' is enabled.
  • isolatedModules also implies preserveConstEnums, so const enums declared in your own .ts files are emitted as real objects and imports of them keep working.

Enums are not erasable syntax

Most TypeScript can be turned into JavaScript by deleting the types: let x: number = 1 becomes let x = 1. Enums can't; they need code generation. A growing set of tools only erase:

  • Node.js type stripping (on by default since Node 23.6 and 22.18) runs .ts files by replacing types with whitespace. It throws on an enum unless you opt into --experimental-transform-types.
  • --erasableSyntaxOnly (TypeScript 5.8) makes tsc report every construct that can't simply be erased:
Not erasableErasable alternative
enum / const enumas const object or a union
namespace with runtime codeES modules
parameter properties constructor(private x: T)declare the field and assign it
import x = require("y"), export =import/export
angle-bracket assertions <T>valuevalue as T

With the flag on, enum Role { ... } fails with This syntax is not allowed when 'erasableSyntaxOnly' is enabled. If your project runs TypeScript through Node directly, turn the flag on so tsc catches these before Node does.

The alternative: as const objects

You can get named constants, a runtime object and a union type without any TypeScript-only syntax:

const Role = {
  Admin: "ADMIN",
  Editor: "EDITOR",
  Viewer: "VIEWER",
} as const;
 
type Role = (typeof Role)[keyof typeof Role];
//   ^? type Role = "ADMIN" | "EDITOR" | "VIEWER"
 
function canPublish(role: Role) {
  return role === Role.Admin || role === Role.Editor;
}
 
canPublish(Role.Admin);   // works like an enum
canPublish("EDITOR");     // and plain literals work too
// @ts-expect-error -- Argument of type '"OWNER"' is not assignable to parameter of type 'Role'.
canPublish("OWNER");
 
const allRoles = Object.values(Role);
//    ^? const allRoles: ("ADMIN" | "EDITOR" | "VIEWER")[]

▶ Try it in the TypeScript Playground

How the type line works, step by step:

  1. as const makes every property readonly with a literal type ("ADMIN", not string).
  2. typeof Role is the object's type: { readonly Admin: "ADMIN"; ... }.
  3. keyof typeof Role is "Admin" | "Editor" | "Viewer".
  4. Indexing with all keys at once gives the union of the values.

Declaring a type and a const with the same name is legal because types and values live in separate namespaces. That's exactly the trick enums use, and it lets callers write Role in both positions.

Compared with a string enum: the values are ordinary strings (so JSON data fits without casting), iteration has no reverse-mapping surprise, and it's erasable. What you lose: the nominal typing ("must go through Role.Admin") and a little ceremony.

Quick check

What is Key?

const Dir = { Up: "up", Down: "down" } as const;
type Key = keyof typeof Dir;

Plain string-literal unions

Often you don't need an object at all. If nobody needs Role.Admin or a list of all values at runtime, a union is the simplest option:

type Theme = "light" | "dark" | "system";
 
function applyTheme(theme: Theme) {
  switch (theme) {
    case "light":
    case "dark":
      return theme;
    case "system":
      return "light";
    default: {
      const unreachable: never = theme; // exhaustiveness check
      return unreachable;
    }
  }
}
 
applyTheme("dark");

You get autocomplete, exhaustiveness checking and zero runtime cost. When you later need the list of values at runtime, derive the union from an array instead of repeating it:

const THEMES = ["light", "dark", "system"] as const;
type Theme = (typeof THEMES)[number];
//   ^? type Theme = "light" | "dark" | "system"
 
function isTheme(value: string): value is Theme {
  return (THEMES as readonly string[]).includes(value);
}

The as readonly string[] widening is needed because includes on a tuple of literals only accepts those literals.

Spot the error

A teammate converts a string enum to an as const object, but one line no longer compiles. Which, and why?

const Status = {
  Active: "active",
  Archived: "archived",
} as const;
 
function label(status: Status) {
  return status.toUpperCase();
}
Show the answer

Status exists only as a value. An enum declared both a value and a type; a const object declares just the value, so status: Status fails with 'Status' refers to a value, but is being used as a type here. Did you mean 'typeof Status'? Add the matching type alias:

const Status = {
  Active: "active",
  Archived: "archived",
} as const;
type Status = (typeof Status)[keyof typeof Status];
 
function label(status: Status) {
  return status.toUpperCase();
}

Decision guide

You needUse
A closed set of strings, no runtime liststring-literal union
A runtime list or named constants (Role.Admin)as const object (or array) + derived type
Values must go through a named constant, not a raw literalstring enum
Bit flags (Read | Write)numeric enum or plain const numbers
Node type stripping or erasableSyntaxOnlyanything except enum
A library's public .d.tsnever const enum

Enums aren't wrong. An existing codebase full of string enums is fine; don't rewrite it for fashion. But in new code, most teams now default to unions and as const objects.

Recap

  • Numeric enums auto-increment from the previous member and have a reverse mapping (Enum[0] === "Name").
  • Numeric enums reject unknown number literals (TS 5.0+) but still accept any number-typed value.
  • String enums have no reverse mapping and are nominal: "ADMIN" isn't assignable to Role.
  • const enum is inlined and emits nothing, which breaks with per-file compilers; isolatedModules guards against it.
  • Enums aren't erasable: Node's type stripping rejects them and --erasableSyntaxOnly (TS 5.8) flags them.
  • as const object + type X = (typeof X)[keyof typeof X] gives constants, a runtime object and a union.
  • Plain unions are the simplest choice when you don't need runtime values.

Interview cards

1 / 7