Running TypeScript in Node without a build step

Node 23 strips types out of the box and TypeScript 5.8 has a flag that keeps your code strippable. For scripts and small services I dropped tsx, though the compiler still has a job.

For a long time, running a TypeScript file in Node meant picking a tool. ts-node first, then tsx once I got tired of the ESM errors, and a dev script in every package.json that wrapped one of them. It worked, and I never loved it: one more thing to install, one more thing that reads the tsconfig slightly differently than tsc does.

Node 22.6 added --experimental-strip-types last August. Node 23.6 switched it on by default in January. And TypeScript 5.8 came out at the end of February with --erasableSyntaxOnly, a flag whose only job is to make sure your code works with that. The three together are the setup I've been waiting for.

What Node actually does

It strips. No type checking, no transforms, no reading your tsconfig.json. The loader hands the file to Amaro, a thin wrapper around SWC's type stripper, which replaces the annotations with blank space so even the line and column numbers in stack traces stay right. What comes out is JavaScript, and Node runs it like any other module.

sh
# Node 23, no flags
node src/server.ts

# Node 22, the LTS, still needs the flag
node --experimental-strip-types src/server.ts

Both print an ExperimentalWarning on start. --disable-warning=ExperimentalWarning silences it, and I do that in the dev script, not globally.

Stripping is all it does, and that draws a clear line. Anything that is only a type goes away and works. Anything TypeScript invented that has runtime behaviour does not:

  • enum and namespace with values in them: they compile to code, and there's no code generation here.
  • Parameter properties, constructor(private db: Db): that's an assignment TypeScript generates for you.
  • import x = require() and export =.
  • Decorators, until the standard ones land in V8.
  • JSX.

There's --experimental-transform-types for the first three, which turns on real transforms, but then you're back to a compile step that happens to live inside Node, with source maps along for the ride. I'd rather not write that syntax. I stopped writing enums a while ago, so the list cost me nothing.

The flag that keeps you honest

erasableSyntaxOnly in TypeScript 5.8 makes every item on that list a compile error. Node would have told you too, at runtime, with a SyntaxError pointing at the enum. I'd rather hear it from tsc in the editor.

Two other things have to line up, both about imports:

  • The .ts extension in relative imports. Node resolves the file you name. import { db } from './db.ts' works, ./db.js points at a file that doesn't exist, and ./db isn't allowed in ES modules. allowImportingTsExtensions lets tsc accept it, and if you do emit for production, rewriteRelativeImportExtensions from 5.7 rewrites it to .js on the way out.
  • import type for types. Node strips import type and inline type specifiers. A plain import { User } from './user.ts' where User is a type stays in the output, the module has no export called User, and it fails at load. verbatimModuleSyntax makes that a compile error, the same reason it was worth it for Vite.

The config that comes out of all this:

json
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
json
// package.json
{
  "type": "module",
  "scripts": {
    "dev": "node --watch --disable-warning=ExperimentalWarning src/server.ts",
    "test": "node --test 'src/**/*.test.ts'",
    "check": "tsc"
  }
}

On Node 22 the dev and test scripts get --experimental-strip-types as well. npm run check is the type check, and it is still the thing that tells you the code is right. Node runs the file either way, that's the point of stripping and also the danger of it.

What moved to this

  • The scripts in scripts/: one-off migrations, data fixers, the "print the resolved config" ones. These were the biggest win, because they used to be .mjs with JSDoc types just to avoid the tool.
  • The small internal services, the ones with a handful of routes and no build pipeline worth the name.
  • Tests, with node --test. The test runner doesn't care that the file is .ts.

What didn't

  • Anything that ships a .d.ts for others. That's tsc's job and nothing else does it.
  • The frontend. Vite is the right tool there, and JSX isn't stripping.
  • A service that has to run on Node 20, which has none of this. tsx is still good and I still recommend it for that. Hiroki Osame's work on it is what made the wait bearable.
  • Anything in node_modules. Node won't strip a dependency's .ts files, on purpose, so packages still have to ship JavaScript.

Running the code and checking the code were always two jobs. Now they're two commands.

Read more here: