node app.ts — Running TypeScript Directly Without ts-node: A Practical Guide to Node.js 22 Type Stripping
In an existing project, I removed ts-node from devDependencies in package.json and changed the script from ts-node src/index.ts to node src/index.ts. It just worked without any additional configuration. This article, based on that experience, summarizes exactly how Node.js 22's native TypeScript execution works, which projects it's suitable for, and what pitfalls to watch out for during a real migration.
Starting with Node.js 22.18.0, you can run .ts files directly without any flags. No ts-node, no tsx, no compilation step. Internally, a library called amaro strips TypeScript type syntax by replacing it with whitespace, then executes it as pure JavaScript.
Core Concepts
Type Stripping: How It Works
The principle is simple. TypeScript type-related syntax is replaced with whitespace, and the rest is executed as-is. It's removal, not transformation. Line numbers remain perfectly identical to the original .ts file.
Why replace types with whitespace instead of deleting them: To keep line and column numbers identical to the original source. Stack traces match
.tsfile locations exactly, without needing source maps.
The internal engine is amaro — a wrapper library officially maintained by the Node.js team. The actual parsing and transformation is handled by a WebAssembly build of SWC (@swc/wasm-typescript), written in Rust. The reason for using the SWC WASM build is that it can be bundled into the Node.js core without native binaries (the same reasoning behind tsx's choice of esbuild). With amaro 1.0 stabilizing in 2025, the official Node.js documentation has started mentioning it as a production consideration.
Version Evolution Timeline
This feature was stabilized carefully in stages. 22.x and 23.x are parallel release lines, not a linear upgrade path.
Node.js 22 LTS Line
| Version | Change |
|---|---|
| 22.6.0 (August 2024) | Initial introduction of --experimental-strip-types flag |
| 22.7.0 (August 2024) | Added --experimental-transform-types (enum/namespace transform support) |
| 22.18.0 (2025) | Direct .ts execution without flags (flag-free stabilization) |
Odd Versions (non-LTS) and Later LTS
| Version | Change |
|---|---|
| 23.x (odd, non-LTS) | strip-types removed from experimental flag |
| 24 (current LTS) | Type stripping adopted as default behavior |
If you're starting a new project today, node app.ts is no longer an experimental feature.
Supported and Unsupported Syntax
Only "erasable" syntax is supported in default mode.
Supported — replaceable with whitespace:
- Type annotations (
: string,: number) interface,typealiases- Generics (
<T>) import type/export type
Not supported in default mode — because they generate runtime code:
// ❌ Throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
enum Direction { Up, Down, Left, Right }
// ❌ Same for this
namespace Utils { export function helper() {} }
// ❌ Parameter properties commonly used in NestJS
constructor(private readonly name: string) {}
// ❌ experimentalDecorators
@Injectable()
class MyService {}The reason enum doesn't work is that replacing it with whitespace would leave Direction as an undefined variable at runtime. "Erasing" alone cannot produce valid JavaScript — it requires actually generating JavaScript code.
What About Using --experimental-transform-types?
The --experimental-transform-types flag enables enum and namespace transformation. However, this is not the recommended default path for two reasons.
First, as the name implies, it's still experimental. Its behavior could change or be removed. Second, it creates an operational burden of explicitly adding the flag to every developer environment and CI in the team. Therefore, in practice, it's recommended to either migrate enums to the as const pattern, or if there are many such constructs, to keep tsx for now.
TC39 standard decorators (currently in the standardization process) are a separate discussion, but based on the experimentalDecorators used in existing NestJS codebases, native execution is not yet possible.
Practical Application
Deciding Whether to Migrate
You don't have to migrate unconditionally. The decision depends on your current project situation.
Basic Execution
In a Node.js 22.18+ environment, with no configuration at all:
# Just works
node app.ts
# File-watch restart during development
node --watch src/index.ts
# For older versions 22.6.0 ~ 22.17.x
node --experimental-strip-types app.tspackage.json Configuration
{
"type": "module",
"engines": {
"node": ">=22.18.0"
},
"scripts": {
"dev": "node --watch src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsc"
}
}Notice that typecheck is separated out? Native execution does not perform type checking. It runs even if there are type errors. You must add tsc --noEmit separately to CI. Specifying the engines field prevents confusion caused by Node.js version mismatches within the team.
tsconfig.json Configuration
A tsconfig combination that works well with native execution:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"erasableSyntaxOnly": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true
}
}Three options are particularly important:
erasableSyntaxOnly(TypeScript 5.8+): Causestscto throw an error if you use enums, namespaces, or parameter properties. Since it's a compiler error rather than a lint warning, you'll get red underlines in your IDE immediately. The entire team shares the constraint "this project only uses natively-executable syntax" at the codebase level.rewriteRelativeImportExtensions(TypeScript 5.7+): If you import./foo.tsin source, it automatically rewrites it to./foo.jsin the build output.noEmit: true: This tsconfig is for type checking only. Actual build output is handled by a separate tsconfig or esbuild/swc.
ESM Import Path Issues
In "type": "module" projects, Node.js ESM requires explicit file extensions.
// ❌ CJS style — doesn't work in ESM
import { foo } from './foo'
// ✅ During development: specify .ts extension (native execution environment)
import { foo } from './foo.ts'
// ✅ Specify .js extension based on build output
import { foo } from './foo.js'Both approaches are valid but for different situations. In a native execution environment during development, referencing with .ts is intuitive. The .js approach is the officially recommended TypeScript pattern — the logic being "reference the file as .js since that's what it will become after building" — and with rewriteRelativeImportExtensions, writing .ts will be automatically converted at build time.
Path Alias Resolution
If you're using tsconfig paths aliases like @/utils/helper, native execution won't resolve them. You can register a custom resolver using the --import flag:
node --import ./register-paths.mjs src/index.ts// register-paths.mjs
import { register } from 'node:module'
import { pathToFileURL } from 'node:url'
register('some-resolver', pathToFileURL('./'))However, this increases configuration complexity. If you're using @/ aliases extensively across dozens of files, the migration cost will be higher than expected. In that case, switching to relative paths or keeping tsx for now is more practical.
Enum Migration
For string enums, migrating to the as const pattern is the most practical approach, and the type-level behavior is nearly identical:
// ❌ Cannot run natively (default mode)
enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
// ✅ Equivalent replacement pattern
const Direction = {
Up: 'UP',
Down: 'DOWN',
Left: 'LEFT',
Right: 'RIGHT',
} as const
type Direction = typeof Direction[keyof typeof Direction]
// Usage is identical
function move(dir: Direction) {
// ...
}
move(Direction.Up)However, numeric enums require caution. Numeric enums support reverse mapping (Direction[0] === 'Up'), but as const objects do not. If you're using numeric enums with reverse mapping, it's not a simple substitution — it requires logic changes.
This direction aligns with the TypeScript team's long-term direction (strengthening isolatedModules compatibility).
Overall Development Workflow
During development, execute quickly with native type stripping; handle type checking separately in CI with tsc --noEmit; and use esbuild or tsc as-is for production builds. There's no need to touch your existing build pipeline.
Pros and Cons Analysis
Quick Comparison
| Item | Native Execution | tsx | ts-node |
|---|---|---|---|
| Cold start | Fastest | Fast | Slow |
| Additional dependencies | None | esbuild | Several |
| enum support | ❌ default mode | ✅ | ✅ |
| Decorator support | ❌ | ✅ | ✅ |
| Path alias support | ❌ | ✅ | ✅ plugin |
| Type checking | ❌ | ❌ | Optional |
| Line number accuracy | ✅ Perfect | Source maps | Source maps |
| Long-term stability | Node.js core | Third-party | Going legacy |
On cold start numbers: The actual difference varies significantly depending on project size and dependencies. The direction that native execution is fastest is clear; for specific benchmark figures, refer to sebastian-staffa.eu's benchmark.
"No type checking" may look like a downside: tsx is the same. The only setup that performed type checking at the execution stage was ts-node's
transpileOnly: falsemode. Most practical projects already separate type checking into the build/CI stage.
Common Mistakes in Practice
Mistake 1: Deploying without type checking
Native execution doesn't catch type errors. Without tsc --noEmit in CI, builds will pass even with type mismatches. When first adopting, add this to the CI pipeline first.
Mistake 2: Trying to apply it to code that depends on experimentalDecorators
Attempting node main.ts in a NestJS project will immediately throw decorator-related errors. NestJS does not currently officially support native execution.
Mistake 3: Using tsconfig.json paths aliases as-is
If you're using path aliases like @/utils/helper, native execution won't resolve them. The --import flag approach described earlier or switching to relative paths is required.
Mistake 4: Applying without checking the Node.js version
Flag-free execution starts from 22.18.0. If you're in the range of 22.6 ~ 22.17, the --experimental-strip-types flag is required. Specify the engines field in package.json and verify that Node.js versions are consistent across the team first.
Conclusion
In Node.js 22.18+, .ts files run directly without additional tools. Three key points:
- amaro replaces types with whitespace and executes — removal, not transformation; line numbers preserved without source maps
- Only erasable syntax is supported — enums, parameter properties, and experimentalDecorators are not available in default mode
- Type checking must be done separately —
tsc --noEmitmust be included in CI
If you're starting now, these three steps are sufficient:
Step 1 — Upgrade Node.js to 22.18 or higher and try running node src/index.ts
Step 2 — Add erasableSyntaxOnly: true to tsconfig to block incompatible syntax early
Step 3 — Add a tsc --noEmit step to CI to ensure type safety
If you're using a framework that depends on decorators like NestJS, it's still right to wait. For new services or utility scripts based on Express/Fastify, you can simply use node where you used to install ts-node.
References
- Running TypeScript Natively | Node.js Official Docs
- Modules: TypeScript | Node.js v26.5.0 API Docs
- Running TypeScript in Node.js: tsx vs. ts-node vs. native — LogRocket Blog
- TSX vs Native Node.js TypeScript — Better Stack
- Node.js Type Stripping Explained — Marco Satanacchio (Hashnode)
- Node.js Native TypeScript: The Complete Guide — Pockit Blog
- Node.js Native TypeScript in 2026: Type Stripping Guide — HireNodeJS
- TypeScript TSConfig: erasableSyntaxOnly Official Docs
- TypeScript TSConfig: rewriteRelativeImportExtensions Official Docs
- nodejs/amaro GitHub Repository
- Node.js Moves Toward Stable TypeScript Support With Amaro 1.0 — InfoQ
- A look at native TypeScript performance benchmark — sebastian-staffa.eu
- A Modern Node.js + TypeScript Setup for 2025 — DEV Community
- Why We Chose Not to Run TypeScript Natively in Node.js — Medium
- Using TypeScript natively in Node.js 22 — dt.in.th