Node.js Runs TypeScript Directly: Type Stripping and the `erasableSyntaxOnly` Trap
Node.js · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: You want to run a `.ts` file with `node server.ts` and delete tsx/ts-node from your toolchain, but half your codebase uses enums and parameter properties that silently refuse to run.
What Changed
You can now hand a .ts file straight to node and it runs — no ts-node, no tsx, no build step:
node server.ts
The capability landed as --experimental-strip-types in Node 22.6, gained --experimental-transform-types in 22.7, and was enabled by default without any flag in Node 23.6 and backported to Node 22.18 on the 22.x LTS line. Node does not type-check. It erases the types and executes the JavaScript underneath.
The crucial constraint: Node only supports erasable TypeScript. Any syntax that must emit runtime code — enum, parameter properties, namespace with values, import x = require() — is rejected unless you opt into the transform flag.
The Old Way vs The New Way
Before — a loader, a config, and a source-map dance for every entry point:
// package.json
{
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"build": "tsc -p tsconfig.build.json",
"script": "tsx scripts/backfill.ts"
},
"devDependencies": { "tsx": "^4.19.0", "typescript": "^5.7.0" }
}
// scripts/backfill.ts — stack traces point at dist/, so you need
// import 'source-map-support/register' or --enable-source-maps everywhere
import { Pool } from 'pg';
enum Mode { Dry, Apply }
class Backfill {
constructor(private db: Pool, private mode: Mode) {}
}
After — Node is the runtime and the stripper; tsc is only a checker:
// package.json
{
"scripts": {
"dev": "node --watch src/server.ts",
"start": "node src/server.ts",
"check": "tsc --noEmit",
"script": "node scripts/backfill.ts"
},
"devDependencies": { "typescript": "^5.8.0" }
}
// scripts/backfill.ts — erasable only
import { Pool } from 'pg';
import type { PoolClient } from 'pg'; // `import type` is mandatory
const Mode = { Dry: 'dry', Apply: 'apply' } as const;
type Mode = (typeof Mode)[keyof typeof Mode];
class Backfill {
#db: Pool;
#mode: Mode;
constructor(db: Pool, mode: Mode) { // no parameter properties
this.#db = db;
this.#mode = mode;
}
async run(c: PoolClient): Promise<void> { /* ... */ }
}
// tsconfig.json — the guardrail that makes the above enforceable
{
"compilerOptions": {
"erasableSyntaxOnly": true, // TS 5.8+: errors on enum, param props, namespaces
"verbatimModuleSyntax": true, // forces `import type` for type-only imports
"module": "nodenext",
"noEmit": true,
"allowImportingTsExtensions": true
}
}
Why It Was Added
The pain wasn't "TypeScript is slow to compile." It was that every TypeScript project had a second runtime. A loader hooked Module._resolveFilename or registered an ESM load hook, transpiled on the fly, cached to disk, and produced JavaScript whose line numbers no longer matched your source. Consequences you have almost certainly hit:
- Stack traces pointing at
dist/server.js:1:41288in a production incident because someone forgot--enable-source-maps. ERR_UNKNOWN_FILE_EXTENSION: .tswhen a worker thread or child process spawned without inheriting the loader flag.- Debuggers attaching to transpiled output and breakpoints landing on the wrong statement.
- A one-off maintenance script requiring the entire dev toolchain to be installed on a box.
- Divergence between
tscoutput and the loader's transpile (differenttargetlowering, different decorator semantics).
Stripping sidesteps all of that. And by refusing non-erasable syntax by default, Node quietly nudges the ecosystem toward TypeScript that is a pure superset of JavaScript — the same direction the "types as comments" TC39 proposal points.
How It Works Underneath
Node embeds Amaro, a thin wrapper around the SWC parser compiled to WebAssembly. When the module loader resolves a .ts file, it parses to an AST, then overwrites every type-only span with spaces and re-emits. Character offsets are preserved byte-for-byte, so line 42 column 9 in your source is line 42 column 9 in what V8 compiles. That is why no source map is needed in the default mode.
flowchart TD
A["node server.ts"] --> B[ESM/CJS loader resolves .ts]
B --> C{Amaro / SWC parse}
C -->|type annotations,<br/>interfaces, `import type`,<br/>`as`, generics| D["Replace span with whitespace<br/>offsets unchanged"]
C -->|enum, param property,<br/>value namespace,<br/>import= require| E{transform-types<br/>flag on?}
E -->|no| F["Throw ERR_UNSUPPORTED_<br/>TYPESCRIPT_SYNTAX"]
E -->|yes| G["Emit real JS + inline source map"]
D --> H[V8 compiles JS<br/>1:1 line/col with source]
G --> H
H --> I[Execute. No type checking<br/>happened at any point]
Two consequences fall directly out of this design:
- No type checking, ever.
const x: number = "nope"runs happily.tsc --noEmitin CI is now load-bearing, not optional. - Erasure is local, not whole-program. Node sees one file at a time and cannot know whether
import { Foo } from './x.ts'is a type or a value. It resolves this by treating ambiguous imports conservatively — which is exactly whyverbatimModuleSyntaxand explicitimport typematter. Also note you must write the.tsextension in relative ESM imports; there is no.js-means-.tsremapping.
Should You Adopt It Yet
Yes, for scripts, CLIs, tests, and internal services on Node 22.18+ or 23.6+. The stripping path is stable, has no measurable startup cost beyond a few milliseconds of parse, and removes a whole dependency and a whole class of source-map bugs.
Wait if:
- You're on Node 20 or below — no support at all, and Node 20 is out of active support.
- You ship a library. You still need
tscto emit.d.tsand JavaScript for consumers on older runtimes and non-Node environments. Type stripping is a runtime convenience, not a publish strategy. - You depend on decorators with metadata,
experimentalDecorators, or heavyenumusage across hundreds of files. The rewrite cost is real. - You need bundling, tree-shaking, minification, or path aliases from
tsconfig.paths. Node doesn't honourpaths; useimportsin package.json (#db/*) instead.
Avoid reaching for --experimental-transform-types as a shortcut. It re-enables enums and parameter properties, but it also re-enables source maps, real code emission, and the divergence between Node's transform and tsc's — you've reintroduced the thing you were trying to delete.
Migration Notes
Go incremental, entry point by entry point:
- Turn on the checker first. Add
"erasableSyntaxOnly": trueand"verbatimModuleSyntax": trueto tsconfig and runtsc --noEmit. Every error is a file Node would refuse. Fix them while your existing loader still works — nothing is broken yet. - Grep for the non-erasable four:
Enums becomerg '^\s*(const )?enum ' --type ts rg 'constructor\([^)]*\b(private|public|protected|readonly)\b' --type ts rg '^\s*namespace |^\s*module [A-Za-z]' --type ts rg 'import .* = require\(' --type tsas constobjects plus a union type. Parameter properties become explicit assignments. Value namespaces become plain modules. - Fix import extensions. Relative imports must name the real file:
./db.ts, not./db.jsor./db. SetallowImportingTsExtensions: truesotscagrees. - Convert one script. Pick a low-risk cron job or CLI. Change
tsx foo.tstonode foo.ts. Verify a deliberate thrown error produces a stack trace with correct line numbers. - Then the dev server, using
node --watch. Thennode --testfor your test files, which strips types the same way. - Keep
tsc --noEmitas a required CI gate. This is the single most important step. Without a loader doing incidental parsing, a type error now reaches production as a runtime error.
What breaks quietly: tsconfig.paths aliases (Node ignores them), emitDecoratorMetadata-dependent DI containers, and any tooling that assumed a dist/ directory exists. Check your Dockerfile — if it runs npm run build and copies dist/, that stage now needs to copy sources instead.
Key takeaway: Node strips types rather than compiling them, so it only runs TypeScript whose syntax disappears without emitting code — turn on `erasableSyntaxOnly` in tsconfig before you delete your loader.
Real-world challenge
A teammate migrates your CLI from `tsx bin/cli.ts` to `node bin/cli.ts` on Node 22.18. It works locally. In CI, one command crashes with a stack trace pointing at line 412 of a 300-line file, and a different environment fails outright with `SyntaxError: Missing initializer in const declaration` on a line containing `const enum Level`. Diagnose both.
Symptom 1 — line numbers past EOF. That's the giveaway that something other than Node's stripper is transforming the file. Type stripping replaces types with whitespace so line/column numbers are preserved exactly. If the trace is off, a bundler, --experimental-transform-types, or a leftover loader (NODE_OPTIONS=--import tsx) is in the pipeline emitting code without source maps.
node -p "process.execArgv.concat(process.env.NODE_OPTIONS ?? '')"
Unset the stale NODE_OPTIONS in the CI job and the trace lines line up again.
Symptom 2 — const enum SyntaxError. Node isn't type-checking; it is parsing TypeScript and erasing annotations. const enum Level { ... } erases to const Level with no initializer, which is a genuine JS syntax error. Nothing type-shaped is wrong — the construct simply cannot be stripped.
// before
const enum Level { Debug, Info }
// after (erasable)
const Level = { Debug: 0, Info: 1 } as const;
type Level = (typeof Level)[keyof typeof Level];
Prevent recurrence: set "erasableSyntaxOnly": true and "verbatimModuleSyntax": true in tsconfig and run tsc --noEmit in CI. The class of bug then fails at type-check time instead of at runtime in one environment.