How to Run TypeScript Natively in Node.js Without a Build Step
For years, running a .ts file meant pulling in ts-node, tsx, or an esbuild wrapper. You paid for that convenience with a dev dependency, a loader hook, and a surprising amount of boilerplate for what amounts to "delete the types and run the file".
These days you can just hand the file to node. The runtime walks the source, deletes the type annotations, and V8 parses what's left. You don't have to install or configure anything.
The name for it is type stripping. Usually it just works, but there are a handful of cases where it refuses to run the file, and I hit most of them the first time I tried to move an older project across. Worth knowing about before you uninstall tsx and find out at 11pm on a Friday.
What type stripping actually does
It isn't compilation in any real sense. Node never opens your tsconfig.json. It doesn't type-check. It doesn't rewrite TypeScript-flavored syntax into JavaScript. What it actually does is scan the file for type annotations and paint over them with spaces before V8 gets a look.
The spaces-instead-of-delete detail matters more than it sounds. Line numbers and columns stay exactly where you wrote them, so stack traces land on the original line and you can debug without source maps most of the time.
This is intentional. The Node team wanted a feature with predictable performance and zero configuration surface. Type erasure is a mechanical transform with no dependencies on your project layout, which is why it could ship directly in core.
The consequence is that anything requiring a real transform is off the table by default. Enums, namespaces with runtime code, parameter properties, experimental decorators: none of those compile down to whitespace. You either avoid them, enable a second flag, or keep a real compiler in the loop.
Running a file
The simplest case has no setup at all. Create a file:
// hello.ts
const greet = (name: string): string => `Hello, ${name}`
console.log(greet('world'))
Then run it:
node hello.ts
In recent Node.js versions the behavior is unflagged. On older 22.x releases you need --experimental-strip-types. You can check what your Node supports with node --help | grep strip.
Node recognizes .ts, .mts, and .cts extensions the same way it handles .js, .mjs, and .cjs. Import resolution follows the same rules as JavaScript, with one important difference covered in the next section.
Import specifiers must include the extension
This is the single gotcha that trips up most people moving from tsc or tsx.
When you import a local file, Node requires the specifier to include the file extension, just like ESM JavaScript:
// Works
import { parse } from './parser.ts'
// Does not work under type stripping
import { parse } from './parser'
This differs from what tsc and bundlers accept, where extensionless imports are the norm. If you are migrating a codebase, this is the change that usually produces the most mechanical churn.
You can keep using .js extensions in your imports if you prefer, and Node will resolve them to the corresponding .ts file. That pattern lets the same source file work under both tsc --outDir builds and native Node execution without modification:
// Works under both tsc and node
import { parse } from './parser.js'
For bare specifiers (packages from node_modules), nothing changes. Node reads package.json exports the same way it always has.
What does not work
Type stripping covers the subset of TypeScript that compiles to nothing. Any construct that generates runtime code is rejected or ignored. The common cases:
Enums. A TypeScript enum becomes a real JavaScript object at runtime. Stripping it would produce references to an object that never gets created, so Node refuses to run the file.
// Throws SyntaxError under --experimental-strip-types
enum Status {
Pending,
Active,
Done,
}
Namespaces with values. Pure type namespaces are fine because they compile to nothing. Namespaces containing runtime code (functions, variables, classes) are rejected for the same reason as enums.
Parameter properties. The shorthand that declares and assigns a field in the constructor signature generates an assignment at runtime, so it fails too.
// Rejected: the `private name` syntax is not a type annotation
class User {
constructor(private name: string) {}
}
Legacy decorators. The experimentalDecorators flavor generates calls to a __decorate helper at runtime and is not handled.
const enum. Even though const enum is meant to be erased, Node does not perform the inlining that tsc does, so it is treated the same as a regular enum and rejected.
The modern TC39 decorators proposal is different and does run under type stripping, because its semantics do not depend on a TypeScript-specific transform.
Enabling the transform mode
If you need enums or parameter properties and do not want to give them up, Node offers a second flag:
node --experimental-transform-types app.ts
This turns on a more aggressive mode where Node actually transforms the unsupported syntax into JavaScript. The cost is that source maps become relevant again (they are emitted automatically) and the startup overhead is slightly higher because the transform is doing real work.
In practice, most projects should keep type stripping and avoid the constructs it rejects. Enums have replacements (union types, as const objects) that work better with tree shaking. Parameter properties save a few lines but are trivial to rewrite. Fighting to keep them only to preserve the old style is rarely worth it.
Enforcing the supported subset with TypeScript
The hard part of adopting type stripping is not Node.js itself. It is catching the unsupported syntax before it reaches production.
TypeScript 5.8 added a compiler flag for exactly this purpose:
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
}
}
erasableSyntaxOnly makes tsc report any syntax that a pure erasure transform cannot handle. Enums, namespaces with values, parameter properties, and legacy decorators all become errors. If tsc --noEmit passes, your code is guaranteed to run under Node type stripping.
verbatimModuleSyntax is a strong companion to it. It forces you to mark type-only imports and exports explicitly with import type and export type, so the erasure step does not accidentally remove something that has a runtime effect.
allowImportingTsExtensions lets tsc accept the .ts extensions in imports that Node requires, so the same source passes both checks.
With these three flags on, tsc becomes a linter for Node type stripping. You do not emit anything from it. The compiler checks the types, confirms the syntax is erasable, and Node does the actual execution.
When to still reach for tsx
Type stripping is not a full replacement for tsx or ts-node in every case. Three scenarios still favor the external tool:
You cannot drop enums or parameter properties. If you are working in a large codebase where auditing every enum is not realistic, tsx will keep running while you migrate. --experimental-transform-types is also an option but the ergonomics are worse than a tool designed for the job.
You depend on path aliases. Node has no concept of a paths mapping from tsconfig.json. Tools like tsx handle that resolution for you. You can approximate it with Node subpath imports (#-prefixed specifiers in package.json), but it is a bigger migration than the rest.
You run on an older Node version. Type stripping requires a flag on 22.6 through 22.x, is unflagged on 23.6+, and is broadly stable on 24+. If your deployment target is still on 20 LTS, tsx is still the right answer.
Outside those cases, the built-in path is simpler. One less dev dependency, one less register hook, one less tool to reason about when something fails.
A realistic migration
Moving an existing project usually looks like this:
- Bump Node to a version with type stripping enabled by default.
- Turn on
erasableSyntaxOnlyand fix everything it flags. Enums become union types oras constobjects. Parameter properties get expanded into explicit fields and constructor assignments. Namespaces with runtime code become plain object exports. - Add the
.ts(or.js) extension to every relative import. This is mechanical and can be done with a codemod. - Replace your
tsxorts-nodeinvocations with directnodecalls inpackage.jsonscripts. - Keep
tsc --noEmitin CI as your type checker. Nothing about the runtime has been checked yet, only parsed.
The last point is the one to internalize. Node does not type-check. A file with wildly wrong types will happily run until the first thrown TypeError. Type stripping does not change your need for a real type checker in CI; it only changes how your program executes at runtime.
Where this goes next
Deno added this in 2020. Bun shipped with it. Node is late.
For a new project I wouldn't install tsx or ts-node. Write code that node can strip. Run tsc --noEmit to check types. Use node file.ts to run things. That's it.