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.
# 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:
enumandnamespacewith 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()andexport =.- 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
.tsextension in relative imports. Node resolves the file you name.import { db } from './db.ts'works,./db.jspoints at a file that doesn't exist, and./dbisn't allowed in ES modules.allowImportingTsExtensionsletstscaccept it, and if you do emit for production,rewriteRelativeImportExtensionsfrom 5.7 rewrites it to.json the way out. import typefor types. Node stripsimport typeand inlinetypespecifiers. A plainimport { User } from './user.ts'whereUseris a type stays in the output, the module has no export calledUser, and it fails at load.verbatimModuleSyntaxmakes that a compile error, the same reason it was worth it for Vite.
The config that comes out of all this:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
// 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.mjswith 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.tsfor others. That'stsc'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.
tsxis 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.tsfiles, 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:
- Modules: TypeScript in the Node.js docs
- Announcing TypeScript 5.8 by Daniel Rosenwasser
- Announcing TypeScript 5.7 by Daniel Rosenwasser, for
rewriteRelativeImportExtensions - tsx by Hiroki Osame