~/blog

Node runs TypeScript now — until you write an enum

published

#node#typescript

TL;DR

Since Node 22.18 / 23.6, node file.ts just works — no flag, no loader, no build step — and the feature is marked stable as of Node 24.12 / 25.2. But it works by erasing type syntax, not compiling it. Anything that needs JavaScript to be generated — enum, namespace with runtime code, constructor parameter properties, import aliases — throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Relative imports must spell out the .ts extension, tsconfig.json is ignored at runtime, and no type checking happens at all.

The problem

You point Node at a perfectly valid TypeScript file:

// enum-test.ts
enum Color {
  Red,
  Green,
}
console.log(Color.Red);

and Node 26 refuses it:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
    at parseTypeScript (node:internal/modules/typescript:59:40)

The same file compiles fine with tsc and runs fine under ts-node or tsx. Meanwhile a file full of interfaces, type aliases and annotations runs without complaint. The line between “works” and “throws” is not obvious until you know what Node is actually doing.

Why it happens

Node does not compile TypeScript — it strips it. The built-in loader (Amaro, wrapping SWC) deletes the syntax that exists only for the type checker and hands the remaining JavaScript to the engine. That is why it is fast and needs no configuration, and it is also the whole limitation: erasure only works for syntax with no runtime meaning.

An enum is not erasable — Color.Red must exist as a real object at runtime, so something has to generate JavaScript for it. Same story for namespace blocks containing runtime code, constructor(private name: string) parameter properties (which silently create and assign a class field), and import foo = require(...) aliases. Node’s position is to refuse rather than transform: an earlier --experimental-transform-types flag that did convert these was removed in Node 26.0, leaving strip-only as the only built-in mode.

The version history in one table:

VersionChange
22.6.0--experimental-strip-types flag introduced
22.7.0--experimental-transform-types added (enums etc., experimental)
22.18.0 / 23.6.0type stripping enabled by default
24.12.0 / 25.2.0type stripping stable
26.0.0--experimental-transform-types removed — strip-only is the only mode

Two more consequences of “erase and run”:

And the quiet one: nothing checks your types. Type stripping deletes annotations without reading them. A file with a blatant type error runs happily — node is your runtime now, not your type checker.

What to do

Write the erasable dialect, and make the compiler enforce it. TypeScript 5.8 shipped a flag for exactly this:

// tsconfig.json
{
  "compilerOptions": {
    "erasableSyntaxOnly": true,        // tsc errors on enum, namespace, parameter properties
    "allowImportingTsExtensions": true, // accept './util.ts' specifiers
    "noEmit": true,
    "module": "nodenext"
  }
}

With erasableSyntaxOnly on, tsc flags every construct Node would reject, at dev time instead of at 3 a.m. in production. The replacements are mechanical:

// instead of: enum Color { Red, Green }
const Color = { Red: 0, Green: 1 } as const;
type Color = (typeof Color)[keyof typeof Color];

// instead of: constructor(private name: string) {}
class User {
  #name: string;
  constructor(name: string) {
    this.#name = name;
  }
}

And keep type checking as its own step, because Node no longer implies one:

npx tsc --noEmit   # CI + pre-commit; node executes, tsc checks

Caveats

References