Headline: Node.js executes TypeScript files directly —
node script.tsworks with no loader, no ts-node, and no build step — by stripping type annotations at load time. Type stripping is on by default since Node.js 23.6 and ships in the 22.18 LTS release, but it only covers erasable syntax: I enforce that with TypeScript 5.8'serasableSyntaxOnlyflag, moved type checking totsc --noEmitin CI, and left my decorator-heavy NestJS services on their existing build.
Key takeaways
- Node.js runs
.tsfiles natively by replacing type annotations with whitespace, a mechanism called type stripping. It is enabled by default since Node.js 23.6 and in the 22.18 LTS release; on Node 22.6–22.17 it sits behind--experimental-strip-types. - Type stripping handles only erasable syntax.
enum,namespacewith runtime code, and constructor parameter properties need the separate--experimental-transform-typesflag. - Node.js never type-checks and never reads
tsconfig.json. The type checker is stilltsc --noEmit, run in CI or a pre-commit hook. - TypeScript 5.8's
erasableSyntaxOnlycompiler option turns every non-erasable construct into a compile error, which guarantees a file Node.js can run. - Relative imports must spell out the
.tsextension, and Node.js refuses to strip types insidenode_modules— published packages still ship JavaScript.
Can Node.js run TypeScript without a build step?
Yes, for most application code. Node.js 22.6 introduced type stripping behind the --experimental-strip-types flag, Node.js 23.6 turned it on by default, and the 22.18 release brought the default-on behavior to the LTS line. On Node.js 24 — the current LTS and my daily runtime — node script.ts simply executes.
// hello.ts
const greet = (name: string): string => `Hello, ${name}`;
console.log(greet('Node 24'));
console.log(process.features.typescript); // 'strip'
Enter fullscreen mode Exit fullscreen mode
The mechanism matters. In strip mode Node.js replaces every type annotation with whitespace instead of compiling the file, so line and column numbers in stack traces match the source exactly, with no source maps involved. process.features.typescript reports the active mode: 'strip', 'transform', or false. The first things I moved over were the places a build step hurt most: one-off utilities in scripts/, config files like drizzle.config.ts, and cron jobs that previously dragged in ts-node just to start.
What TypeScript syntax does type stripping not support?
Erasable syntax is anything that can be deleted without changing runtime behavior: type annotations, interface, type aliases, generics, satisfies, and as casts. Type stripping handles all of it. Four constructs generate JavaScript output and are therefore not erasable: enum, namespace with runtime values, constructor parameter properties (constructor(private db: Database)), and import x = require(). Files that use them need --experimental-transform-types, which switches Node.js from stripping to a full SWC-based transform with source maps.
I avoid the transform flag entirely and keep everything erasable. Enums were the only construct I actually had to migrate, and the replacement is arguably better TypeScript anyway:
// instead of: enum Role { Admin = 'admin', Editor = 'editor' }
const ROLES = ['admin', 'editor'] as const;
type Role = (typeof ROLES)[number];
Enter fullscreen mode Exit fullscreen mode
A const array plus a derived union type is erasable, serializes cleanly, and produces none of the surprising runtime objects enums do.
How do I guarantee my code stays erasable?
TypeScript 5.8 added the erasableSyntaxOnly compiler option, which turns every non-erasable construct into a compile error. With it enabled, code that passes the type checker is guaranteed to run under Node.js strip mode — the constraint is enforced by tooling instead of code review.
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"module": "nodenext"
}
}
Enter fullscreen mode Exit fullscreen mode
Three companions earn their place. verbatimModuleSyntax forces type-only imports to be written as import type, so stripping an import can never change the runtime module graph. allowImportingTsExtensions permits the explicit ./util.ts specifiers Node.js requires — the runtime does not rewrite import paths. And one fact that reframes the whole setup: Node.js never reads tsconfig.json. The config exists for the editor and for tsc; the runtime ignores it in both strip and transform modes.
Where does type checking happen if Node strips types?
Nowhere at runtime. Node.js discards annotations without validating them, which is the same trade-off esbuild-based runners like tsx made years ago — the ecosystem already proved it workable. My setup: tsc --noEmit runs as a CI gate and in a pre-commit hook, and the editor surfaces errors live through the TypeScript language server. The honest failure mode: a file with a type error still executes, so the gate — not the runtime — is what keeps it from shipping. Type stripping makes broken types quieter, not safer, which is exactly why the gate stays mandatory.
What did I replace, and what still needs tsx or a build?
Native execution replaced ts-node and tsx for me in every script-shaped context, but not everywhere. The comparison I actually use:
| Runner | Type checks? | Non-erasable syntax | When I reach for it |
|---|---|---|---|
| Node.js type stripping | No | No (strip mode) | Scripts, configs, cron jobs on Node 22.18+ |
| tsx | No | Yes, plus JSX and path aliases | Older Node versions or tsconfig path aliases |
| ts-node | Optional | Yes | Legacy projects that already depend on it |
| tsc build | Yes | Yes | Published libraries and decorator-heavy apps |
The dependency count is the underrated win. A repo whose scripts run on bare Node.js needs no loader in devDependencies and no --import wiring, and node --watch script.ts covers the dev loop that tsx's watch mode used to handle.
Which projects should keep their build step?
My NestJS services kept theirs. NestJS dependency injection relies on emitDecoratorMetadata, and Node.js emits no decorator metadata in either strip or transform mode, so constructor injection loses the type information it resolves providers with — native execution is not an option there. The other hard boundaries: JSX/TSX files are out of scope, Node.js does not resolve tsconfig path aliases, and type stripping deliberately skips node_modules, so anything published to npm must ship compiled JavaScript. Libraries need tsc for .d.ts output regardless.
FAQ
Q: Do I still need ts-node or tsx?
A: Not for erasable-syntax scripts on Node.js 22.18 or newer. tsx still earns its keep for JSX, tsconfig path aliases, and older Node versions; ts-node I no longer install at all.
Q: Does Node.js type-check the TypeScript it runs?
A: No. Type stripping discards annotations without validating them. Run tsc --noEmit in CI or a pre-commit hook to catch type errors before they ship.
Q: Does Node.js read tsconfig.json?
A: No. The runtime ignores it in both strip and transform modes. tsconfig.json configures the editor and tsc only.
Q: Can I use enums with native TypeScript execution?
A: Only behind --experimental-transform-types. A const array or const object with a derived union type is erasable and behaves more predictably — I migrated my enums instead of enabling the flag.
Q: Do TypeScript files inside node_modules work?
A: No. Node.js deliberately refuses to strip types in node_modules, so published packages must ship compiled JavaScript with declaration files.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.