TypeScript enum is not supported in strip-only mode: fixing Node's type stripping
Node.js · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: Node now runs .ts files directly, but enums, namespaces and constructor parameter properties throw at startup. Here's why, and the three ways out.
What Changed
If you just ran node src/index.ts on a modern Node and got ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript enum is not supported in strip-only mode, nothing is broken — you've hit the deliberate limit of Node's built-in TypeScript support. Node can now execute .ts, .mts and .cts files directly, with no ts-node, tsx, or build step. It landed behind --experimental-strip-types in Node 22.6, and became on by default in Node 23.6 and in the Node 22.18 backport, so a lot of teams meet it for the first time simply by upgrading.
The catch: Node does not compile TypeScript. It only erases it. Any TypeScript construct that has to emit JavaScript at runtime — enum, namespace with a body, constructor parameter properties, import x = require() — throws instead of running.
The Old Way vs The New Way
Before, you needed a loader or a compile step, and enums worked because something actually generated code for them:
// package.json — the old way
{
"scripts": {
"dev": "ts-node --transpile-only src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
}
}
// src/order.ts — compiles fine under tsc, throws under Node's stripper
export enum OrderStatus { Pending = 'pending', Paid = 'paid' }
export class OrderService {
constructor(private readonly db: Db) {} // parameter property
}
Now, with no toolchain at all — but only if the source is erasable:
// src/order.ts — runs directly via `node src/order.ts`
export const OrderStatus = {
Pending: 'pending',
Paid: 'paid',
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
export class OrderService {
readonly db: Db;
constructor(db: Db) { // explicit field + assignment
this.db = db;
}
}
$ node src/index.ts # Node 23.6+ / 22.18+: just works
Why It Was Added
The pain being removed is the dev-time TypeScript tax: a watcher, a dist/ directory, source maps, ts-node/esm loader hooks that break on every Node minor, and the class of bugs where the thing you debugged is not the thing you shipped.
The reason it is strip-only is subtler and more important. Erasing types is a purely local operation — you can do it without a type checker, without resolving imports, and without shifting a single byte. That makes it fast enough to run on every startup and safe enough to ship in core. Supporting enum would require Node to become a real transpiler with real codegen, real source maps, and real semantics arguments (const enums? declaration merging?). The Node team drew the line at "types are comments" — which, since TypeScript 5.8 shipped the erasableSyntaxOnly compiler flag, is now an officially supported way to write TypeScript.
How It Works Underneath
Node embeds Amaro, a thin wrapper over SWC's TypeScript parser. When the ESM/CJS loader sees a .ts extension, it parses the source and replaces every type-only range with spaces of the same length. Byte offsets, line numbers and column numbers are unchanged, so V8 stack traces are correct with no source map at all.
flowchart TD
A["node src/index.ts"] --> B[Module loader sees .ts]
B --> C[Amaro / SWC parses to AST]
C --> D{Node emits runtime JS?}
D -- "interface, type,<br/>generics, as, : Foo" --> E[Overwrite range with spaces<br/>offsets preserved]
D -- "enum, namespace,<br/>param property" --> F["throw ERR_UNSUPPORTED_<br/>TYPESCRIPT_SYNTAX"]
E --> G[Valid JS, identical positions]
G --> H[V8 compile + execute]
F --> I[Process exits before any code runs]
H -.-> J["--experimental-transform-types:<br/>real codegen + source map required"]
Two consequences fall straight out of this design:
- No type checking happens.
node file.tswill happily run code thattscrejects. You still needtsc --noEmitin CI. - Imports are not analysed. The stripper cannot tell whether
import { User } from './types.ts'is a type or a value, so it leaves it in. IfUseris an interface, you get a runtimeSyntaxError: does not provide an export named 'User'. Fix:verbatimModuleSyntax: trueandimport type.
Also note that files under node_modules are not stripped — published packages must still ship JavaScript.
Should You Adopt It Yet
Yes for scripts, CLIs, tests and local dev servers. The startup cost is a few milliseconds and you delete an entire dependency.
Cautiously for application servers. It's stable enough in Node 24 LTS, but you lose the compile step that was also your type-check gate — make sure CI runs tsc --noEmit or you'll ship type errors.
Wait if you publish a library (you still need a build), you rely on tsconfig.json semantics such as paths aliases (Node ignores tsconfig entirely — it does not read it), or your codebase is built on decorators plus dependency injection, which lean heavily on parameter properties.
Avoid reaching for --experimental-transform-types as the default escape hatch. It does support enums and namespaces, but it reintroduces codegen, shifts line numbers, and requires source maps to debug — you've quietly rebuilt the build step you were trying to delete.
Migration Notes
Do it in this order:
- Fail fast at type-check time. Add to
tsconfig.json:
{
"compilerOptions": {
"erasableSyntaxOnly": true, // TS 5.8+: errors on enum/namespace/param props
"verbatimModuleSyntax": true, // forces `import type` for type imports
"rewriteRelativeImportExtensions": true,
"module": "nodenext"
}
}
tsc --noEmit now reports every file Node would reject, all at once, instead of one crash per startup.
- Grep for the offenders before the upgrade:
grep -rnE '\benum\b|\bnamespace\b|import .* = require\(' src/
grep -rnE 'constructor\([^)]*(private|public|protected|readonly)' src/
Replace enums with
as constobjects (the union type keeps call sites identical), expand parameter properties into explicit fields, and convertnamespaceinto modules.Use explicit relative extensions —
import './order.ts', not'./order'. Node's resolver does no extension guessing;rewriteRelativeImportExtensionskeepstscbuilds working from the same source.Keep the build for anything you publish. Consumers on older Node, or bundlers, still expect
.jsplus.d.ts.
Key takeaway: Node's built-in TypeScript support erases types without rewriting code, so any syntax that emits runtime JavaScript — enum, namespace, parameter properties — must go or be opted into with --experimental-transform-types.
Real-world challenge
Your team upgrades CI from Node 20 to Node 24 and drops ts-node in favour of `node src/index.ts`. Most files run, but one service crashes at startup with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. A teammate adds `--experimental-transform-types` and the crash goes away — but now production stack traces point to wrong line numbers, and a second file starts throwing `SyntaxError: The requested module './types.js' does not provide an export named 'OrderStatus'`. Diagnose both.
Crash 1 — unsupported syntax. Grep the failing file for runtime-emitting TS:
grep -rnE '\b(enum|namespace)\b|constructor\([^)]*\b(private|public|protected|readonly)\b' src/
The fix that scales is deleting the construct, not enabling the transform:
// was: export enum OrderStatus { Paid = 'paid' }
export const OrderStatus = { Paid: 'paid' } as const;
export type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus];
Wrong line numbers. --experimental-transform-types rewrites code, so positions shift. Strip-only mode never needs source maps; transform mode does. Run Node with --enable-source-maps and ensure "sourceMap": true — or better, go back to strip-only by removing the enum.
Crash 2 — value import of a type. Strip-only mode erases types but cannot tell whether import { OrderStatus } is a type or a value, so it leaves the import in place and ESM resolution fails at runtime. Turn on verbatimModuleSyntax and use import type { ... } for every type-only import.
Lock it in: add "erasableSyntaxOnly": true to tsconfig so tsc --noEmit in CI fails on this class of syntax before Node ever sees it.