Skip to main content
TypeScript · lesson 21 of 25
TypeScript intermediate Lesson 21 of 25

Enums, and Why Node Won't Strip Them

Numeric and string enums, the reverse-mapping object they emit, const enum inlining, and why erasableSyntaxOnly and plain node refuse to run them.

By SkillByExample · Published

typescript enums

Examples run on TypeScript 7.0.2 and Node v24.18.1, each as a standalone file in a folder whose package.json has "type": "module". npx tsc --noEmit --module nodenext file.ts type-checks a file; npx tsx file.ts runs one that uses syntax plain node can’t.

Numeric Enums and the Object They Emit

An enum isn’t just a type-level label. It compiles to a real object, and logging the enum itself shows what’s really there.

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

console.log(Direction);
console.log(Direction.Up, Direction.Left);
console.log(Direction[2]);

function move(direction: Direction): string {
  return `moving ${Direction[direction]}`;
}

console.log(move(Direction.Right));
$ npx tsc --noEmit --module nodenext numeric-enum.ts
$ npx tsx numeric-enum.ts
{
  '0': 'Up',
  '1': 'Down',
  '2': 'Left',
  '3': 'Right',
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3
}
0 2
Left
moving Right

Direction isn’t erased — it’s an object with eight keys, four forward (Up: 0) and four reverse ('0': 'Up'). Direction[direction] in move relies on that reverse half existing at runtime. Each member defaults to one more than the previous, starting at 0, which is why Up is 0 and Right is 3.

String Enums Have No Reverse Mapping

Giving each member an explicit string value changes what gets generated.

enum LogLevel {
  Info = "INFO",
  Warn = "WARN",
  Error = "ERROR",
}

console.log(LogLevel);
console.log(LogLevel.Warn, LogLevel["INFO"]);

function log(level: LogLevel, message: string): void {
  console.log(`[${level}] ${message}`);
}

log(LogLevel.Error, "disk full");
log("ERROR", "disk full");
$ npx tsc --noEmit --module nodenext string-enum.ts
string-enum.ts(8,37): error TS2551: Property 'INFO' does not exist on type 'typeof LogLevel'. Did you mean 'Info'?
string-enum.ts(15,5): error TS2345: Argument of type '"ERROR"' is not assignable to parameter of type 'LogLevel'.

LogLevel["INFO"] fails because there is no reverse entry: the compiled object only has Info, Warn and Error as keys, mapping to the strings. And log("ERROR", ...) is rejected even though "ERROR" is exactly LogLevel.Error’s value — a string enum member is its own nominal type, not just an alias for its string. Remove both bad lines and run it:

$ npx tsx string-enum.ts
{ Info: 'INFO', Warn: 'WARN', Error: 'ERROR' }
WARN
[ERROR] disk full

const enum: Inlined, With No Object at All

const enum removes the runtime object entirely. Every reference to a member is replaced by its literal value at compile time.

const enum Direction {
  Up,
  Down,
  Left,
  Right,
}

const d = Direction.Left;
console.log(d);
console.log(typeof Direction);
$ npx tsc --noEmit --module nodenext const-enum.ts
const-enum.ts(10,20): error TS2475: 'const' enums can only be used in property or index access expressions or the right hand side of an import declaration or export assignment or type query.

Direction.Left is fine — that’s a property access, which tsc can inline. typeof Direction as a runtime expression is not: it asks for an object that const enum never generates. Drop that line and look at the emitted JavaScript itself:

$ npx tsc --module nodenext --outDir out const-enum.ts
$ cat out/const-enum.js
const d = 2 /* Direction.Left */;
console.log(d);
export {};
$ npx tsx const-enum.ts
2

No Direction object anywhere — Direction.Left became the number 2, with a comment naming where it came from. That’s the appeal of const enum: zero runtime cost, but only within one file. A file compiled in isolation has no way to inline a const enum declared somewhere else, so under isolatedModules — which tsc --init turns on by default, as Setting Up TypeScript shows — importing a const enum from another file doesn’t error. It silently stops being inlined instead: tsc falls back to emitting a real object for the enum, exactly like a plain enum, and the importing file keeps an ordinary property access, Direction.Left, rather than the literal 2. Keep a const enum and its uses in the same file to get the inlining this section showed.

Why enum Isn’t Erasable

Node’s built-in type stripping, and tsc --erasableSyntaxOnly, both require that deleting the type-only parts of a file leaves working JavaScript behind. An enum doesn’t qualify, because it still has to emit that mapping object — numeric, string or const.

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

console.log(Direction.Up);
$ npx tsc --noEmit --module nodenext --erasableSyntaxOnly enum.ts
enum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
$ node enum.ts
node:internal/modules/run_main:107
    triggerUncaughtException(
    ^

file:///C:/tmp/ts-gap/enum.ts:1
  > enum Direction {
      Up,
      Down,
      Left,
      Right,
  > }

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
    at parseTypeScript (node:internal/modules/typescript:68:40)
    at processTypeScriptCode (node:internal/modules/typescript:146:42)
    at stripTypeScriptModuleTypes (node:internal/modules/typescript:209:22)
    at ModuleLoader.<anonymous> (node:internal/modules/esm/translators:680:29)
    at #translate (node:internal/modules/esm/loader:434:20)
    at afterLoad (node:internal/modules/esm/loader:502:29)
    at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:507:12)
    at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:560:36)
    at afterResolve (node:internal/modules/esm/loader:607:52)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:613:12) {
  code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'
}

Node.js v24.18.1

Both refusals come from the same fact: unlike an interface or a type annotation, an enum compiles to code, not just to nothing. tsc without --erasableSyntaxOnly compiles it fine, and tsx runs it fine — it’s only the two “strip, don’t compile” paths that reject it. erasableSyntaxOnly is the flag to turn on specifically to catch this before Node does, in CI, rather than find out in production.

The as const Replacement

An object literal marked as const, paired with a union type derived from its values, gives the same “one of these named things” shape without any of the restrictions above.

const Direction = {
  Up: "UP",
  Down: "DOWN",
  Left: "LEFT",
  Right: "RIGHT",
} as const;

type Direction = (typeof Direction)[keyof typeof Direction];

function move(direction: Direction): string {
  return `moving ${direction}`;
}

console.log(Direction);
console.log(move(Direction.Up));
$ npx tsc --noEmit --module nodenext --erasableSyntaxOnly as-const-direction.ts
$ node as-const-direction.ts
{ Up: 'UP', Down: 'DOWN', Left: 'LEFT', Right: 'RIGHT' }
moving UP

This passes --erasableSyntaxOnly and runs under plain node, with no tsx or tsc needed — const, as const and a type alias all disappear completely, leaving an ordinary object literal. type Direction reuses the name of the const Direction value; TypeScript keeps type and value namespaces separate, the same trick the enum itself relied on. The type is "UP" | "DOWN" | "LEFT" | "RIGHT", so move("UP") would type-check too — a plain string literal satisfies it, where the string-enum version earlier rejected "ERROR" outright. That’s the trade-off: the as const version accepts any matching literal, while a true enum insists on its own named members.

Practice

1. Add None = -1 as the first member of the numeric Direction enum and log the enum object plus Direction.Up. Does the auto-numbering of the later members change?

Solution
enum Direction {
  None = -1,
  Up,
  Down,
  Left,
  Right,
}

console.log(Direction);
console.log(Direction.Up, Direction[0]);
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
{
  '0': 'Up',
  '1': 'Down',
  '2': 'Left',
  '3': 'Right',
  None: -1,
  '-1': 'None',
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3
}
0 Up

No — Up is still 0. Auto-numbering continues from the previous member’s value plus one, and None is -1, so Up is -1 + 1 = 0. None gets its own reverse entry ('-1': 'None') alongside the rest.

2. Rewrite the LogLevel string enum as an as const object with a derived union type, and check whether log("ERROR", "disk full") — rejected by the enum version — now compiles.

Solution
const LogLevel = {
  Info: "INFO",
  Warn: "WARN",
  Error: "ERROR",
} as const;

type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];

function log(level: LogLevel, message: string): void {
  console.log(`[${level}] ${message}`);
}

console.log(LogLevel);
log(LogLevel.Error, "disk full");
log("ERROR", "disk full");
$ npx tsc --noEmit --module nodenext practice-2.ts
$ node practice-2.ts
{ Info: 'INFO', Warn: 'WARN', Error: 'ERROR' }
[ERROR] disk full
[ERROR] disk full

It compiles, with no --erasableSyntaxOnly violation and no tsx needed. LogLevel the type is "INFO" | "WARN" | "ERROR", and "ERROR" is a member of that union — unlike the string enum, where LogLevel.Error was a distinct nominal type that a plain string literal couldn’t satisfy.

3. Try to spread the numeric const enum Direction into an array ([...Direction] or { ...Direction }) instead of accessing a single member. What error does tsc give, and how is it different from the plain enum case?

Solution
const enum Direction {
  Up,
  Down,
  Left,
  Right,
}

const all = [Direction.Up, Direction.Down, Direction.Left, Direction.Right];
console.log(all);

const spread = { ...Direction };
console.log(spread);
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(11,21): error TS2475: 'const' enums can only be used in property or index access expressions or the right hand side of an import declaration or export assignment or type query.

TS2475, not TS1294 — this isn’t about erasability, it’s about const enum specifically. There is no Direction object to spread; only property accesses like Direction.Up can be inlined. A plain enum would allow { ...Direction } without complaint, because it really does have an object to spread. Remove the spread line and the array of inlined members still runs:

$ npx tsx practice-3.ts
[ 0, 1, 2, 3 ]

4. Does --erasableSyntaxOnly reject a string enum, and a const enum, the same way it rejected the numeric one?

Solution
enum LogLevel {
  Info = "INFO",
  Warn = "WARN",
  Error = "ERROR",
}

console.log(LogLevel.Error);
$ npx tsc --noEmit --module nodenext --erasableSyntaxOnly practice-4.ts
practice-4.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
const enum Direction {
  Up,
  Down,
  Left,
  Right,
}

console.log(Direction.Up);
$ npx tsc --noEmit --module nodenext --erasableSyntaxOnly practice-4b.ts
practice-4b.ts(1,12): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

Yes, the identical TS1294 both times. The flag doesn’t care whether the enum is numeric, string or const — enum as a keyword always compiles to something, so it’s always rejected, even on the const enum whose whole appeal is compiling to nothing at the use site. Only the as const object pattern passes.

Frequently Asked Questions

Why does console.log of a numeric enum print numbers and names both ways?
A numeric enum compiles to an object with a forward mapping (name to number) and a reverse mapping (number to name) added in a loop at the bottom of the generated code. That object is real, allocated JavaScript — it is why enum.Up and enum[0] both work, and why the enum isn't free at runtime.
Why don't string enums have a reverse mapping?
TypeScript only generates the reverse entries for numeric members, because the forward and reverse keys would collide for strings whose value equals another member's name. A string enum's compiled object holds only the name-to-value direction.
What is the difference between enum and const enum?
A plain enum compiles to a real object that exists at runtime. A const enum has no object at all — every reference to one of its members is replaced with the literal value at compile time. That makes const enum members usable only in positions tsc can inline, such as property access, not in a spread or a bare value reference.
Should new TypeScript code use enum or an as const object?
Prefer an object literal marked as const, with a union type derived from its values. It compiles to a plain object with no runtime cost beyond the object itself, accepts matching string literals without a cast, and survives erasableSyntaxOnly and Node's own type stripping, none of which is true of enum.
What does TS1294 mean?
TS1294, 'This syntax is not allowed when erasableSyntaxOnly is enabled,' marks any construct that does not simply disappear when type annotations are deleted. enum is one of them, because it must still emit the mapping object. The fix is to compile with the flag off, or to replace the enum with an as const object and a union type.