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:

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:

  1. No type checking, ever. const x: number = "nope" runs happily. tsc --noEmit in CI is now load-bearing, not optional.
  2. 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 why verbatimModuleSyntax and explicit import type matter. Also note you must write the .ts extension in relative ESM imports; there is no .js-means-.ts remapping.

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:

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:

  1. Turn on the checker first. Add "erasableSyntaxOnly": true and "verbatimModuleSyntax": true to tsconfig and run tsc --noEmit. Every error is a file Node would refuse. Fix them while your existing loader still works — nothing is broken yet.
  2. Grep for the non-erasable four:
    rg '^\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 ts
    
    Enums become as const objects plus a union type. Parameter properties become explicit assignments. Value namespaces become plain modules.
  3. Fix import extensions. Relative imports must name the real file: ./db.ts, not ./db.js or ./db. Set allowImportingTsExtensions: true so tsc agrees.
  4. Convert one script. Pick a low-risk cron job or CLI. Change tsx foo.ts to node foo.ts. Verify a deliberate thrown error produces a stack trace with correct line numbers.
  5. Then the dev server, using node --watch. Then node --test for your test files, which strips types the same way.
  6. Keep tsc --noEmit as 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.