TypeScript · lesson 21 of 25
Beginner
6Intermediate
16- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 Enums, and Why Node Won't Strip Them
- 22 Narrowing and Control Flow Analysis
- 23 Type-Checking JavaScript with JSDoc and checkJs
- 24 Resource Cleanup with using
- 25 TypeScript Interview Prep
Also part of TypeScript
3Enums, 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
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.