For over a decade, running TypeScript in Node.js meant one thing: compile first, run later. Whether you used tsc, ts-node, tsx, esbuild, or swc, there was always an intermediate step between your .ts files and actual execution. That era is ending.
Starting with Node.js 22 and now stable in Node.js 25, you can run TypeScript files directly:
node app.ts
No build step. No tsconfig.json required. No external dependencies. Just Node.js and your TypeScript code.
This isn't a toy feature or a niche experiment. It's a fundamental shift in the Node.js ecosystem that affects every TypeScript developer. In this guide, we'll cover exactly how it works, what it can and can't do, and how to adopt it in real projects.
How It Works: Type Stripping, Not Type Checking
The key insight behind Node.js's approach is radical in its simplicity: strip the types, run the JavaScript.
Node.js doesn't compile TypeScript. It doesn't type-check your code. It doesn't transform your syntax. It literally removes type annotations and runs what's left as regular JavaScript.
Consider this TypeScript file:
// app.ts
interface User {
name: string;
age: number;
}
function greet(user: User): string {
return `Hello, ${user.name}! You are ${user.age} years old.`;
}
const user: User = { name: 'Alice', age: 30 };
console.log(greet(user));
Node.js transforms this to roughly:
function greet(user) {
return `Hello, ${user.name}! You are ${user.age} years old.`;
}
const user = { name: 'Alice', age: 30 };
console.log(greet(user));
The interface User declaration? Gone. The : User type annotation? Stripped. The : string return type? Removed. What's left is perfectly valid JavaScript that V8 can execute immediately.
This is called "erasable syntax" — TypeScript syntax that can be removed without changing the runtime behavior of the code. And it covers the vast majority of TypeScript features developers use daily.
The Implementation: Amaro and SWC
Under the hood, Node.js uses a library called Amaro to perform the type stripping. Amaro is a thin wrapper around @swc/wasm-typescript — the WebAssembly build of SWC's TypeScript parser.
The pipeline looks like this:
your-file.ts → Amaro (SWC WASM) → type-stripped JS → V8 execution
This architecture has several important implications:
It's fast. SWC is written in Rust and compiled to WebAssembly. The type stripping operation is orders of magnitude faster than a full
tsccompilation because it only parses and strips — it doesn't resolve types, check constraints, or emit declaration files.It's built-in. Amaro ships with Node.js itself. No
npm installneeded. Nonode_modulespollution. It's part of the runtime.It's limited by design. Because it uses SWC for stripping (not full compilation), only erasable TypeScript syntax is supported in the default mode.
The Timeline: From Experimental to Stable
The journey to native TypeScript has been systematic:
| Version | Milestone |
|---|---|
| Node.js 22.6.0 (July 2024) |
--experimental-strip-types flag introduced |
| Node.js 22.7.0 (August 2024) |
--experimental-transform-types added for enums |
| Node.js 23.x (Late 2024) | Refinements, broader testing |
| Node.js 22.18.0 / 23.6.0 (2025) | Type stripping enabled by default (no flag needed) |
| Node.js 25.2.0 (2026) | Stable release, experimental warnings removed |
As of early 2026, if you're running Node.js 22.18+ or any recent Node.js version, type stripping works out of the box. No flags, no configuration.
What Works: Erasable TypeScript Syntax
The following TypeScript features work perfectly with Node.js type stripping because they can be cleanly removed without affecting runtime behavior:
Type Annotations
// All of these just get stripped
const name: string = 'hello';
function add(a: number, b: number): number { return a + b; }
const items: Array<string> = ['a', 'b', 'c'];
Interfaces and Type Aliases
interface Config {
host: string;
port: number;
debug?: boolean;
}
type DatabaseURL = `postgres://${string}`;
// These are entirely erased — they don't exist at runtime
Generics
function identity<T>(value: T): T {
return value;
}
class Container<T> {
constructor(private value: T) {}
get(): T { return this.value; }
}
Type Assertions and as Casts
const input = document.getElementById('name') as HTMLInputElement;
const data = JSON.parse(body) as ApiResponse;
satisfies Operator
const config = {
host: 'localhost',
port: 3000,
} satisfies Config;
Optional Chaining and Nullish Coalescing (Already JS)
// These are JavaScript syntax, not TypeScript — they work regardless
const name = user?.profile?.name ?? 'Anonymous';
Utility Types
type ReadonlyUser = Readonly<User>;
type PartialConfig = Partial<Config>;
type UserKeys = keyof User;
// All erased at type-strip time
What Doesn't Work: Non-Erasable Syntax
This is where things get nuanced. Some TypeScript features produce runtime code — they can't simply be stripped because removing them changes behavior. These are called "non-erasable" features.
Enums (Classic Enums)
// ❌ Runtime error with type stripping only
enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
Enums generate JavaScript objects at runtime. Stripping the enum keyword would leave invalid syntax. To use enums, you need the --experimental-transform-types flag:
node --experimental-transform-types app.ts
This enables SWC to transform (not just strip) these constructs into valid JavaScript.
Better alternative: Use const objects with as const instead of enums:
// ✅ Works with plain type stripping
const Direction = {
Up: 'UP',
Down: 'DOWN',
Left: 'LEFT',
Right: 'RIGHT',
} as const;
type Direction = typeof Direction[keyof typeof Direction];
Parameter Properties
// ❌ Requires --experimental-transform-types
class User {
constructor(public name: string, private age: number) {}
}
Parameter properties (public, private, protected, readonly in constructor parameters) generate assignment code. The shorthand public name: string becomes this.name = name; in the constructor body. This transformation goes beyond stripping.
Alternative:
// ✅ Works with plain type stripping
class User {
name: string; // Type annotation — stripped
private _age: number;
constructor(name: string, age: number) {
this.name = name;
this._age = age;
}
}
Legacy Decorators and emitDecoratorMetadata
// ❌ Not supported — these generate runtime metadata
@Controller('/users')
class UserController {
@Get('/:id')
getUser(@Param('id') id: string) { ... }
}
Legacy (experimental) decorators and emitDecoratorMetadata emit reflection metadata at runtime. This is common in frameworks like NestJS, TypeORM, and Angular.
Note: TC39 Stage 3 decorators (the modern standard) are a JavaScript feature and are handled separately by Node.js's JavaScript engine.
Namespaces with Runtime Merging
// ❌ Not supported — namespaces generate IIFEs
namespace Validation {
export function isEmail(str: string): boolean {
return str.includes('@');
}
}
TypeScript namespaces compile to IIFEs (Immediately Invoked Function Expressions). They can't be stripped.
Alternative: Use ES modules instead:
// validation.ts
export function isEmail(str: string): boolean {
return str.includes('@');
}
Important Constraints and Gotchas
No Type Checking
This is the most important thing to understand: Node.js does not type-check your code. If you have a type error:
const name: number = "hello"; // Type error!
Node.js will happily strip the : number annotation and run the code. It has no idea (and no interest in whether) the types are correct. That's still tsc's job.
Your workflow becomes:
# Development: just run it
node app.ts
# CI/CD: type-check separately
npx tsc --noEmit
This is actually a significant speed improvement. During development, you skip type checking entirely and get instant execution. Type checking happens in your editor (via the TypeScript language server) and in CI.
Import Specifiers: .ts vs .js
This is one of the trickiest migration issues. Node.js requires explicit file extensions in imports when using ES modules:
// ❌ Ambiguous — Node.js doesn't know which file you mean
import { greet } from './utils';
// ✅ Explicit .ts extension
import { greet } from './utils.ts';
However, the TypeScript compiler historically recommended using .js extensions in imports (even for .ts files), because after compilation the files would have .js extensions:
// This is what tsc traditionally wanted
import { greet } from './utils.js';
With Node.js native type stripping, you should use the .ts extension in your imports. But this creates a conflict if you also need to compile with tsc for other purposes (like generating declaration files).
The solution: TypeScript 5.7+ introduced the rewriteRelativeImportExtensions compiler option in tsconfig.json:
{
"compilerOptions": {
"rewriteRelativeImportExtensions": true
}
}
This tells tsc to accept .ts import specifiers and rewrite them to .js in the output. Now you can use .ts imports everywhere.
No tsconfig.json Paths
TypeScript path aliases defined in tsconfig.json don't work:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
// ❌ Node.js doesn't read tsconfig.json
import { db } from '@/database';
Node.js doesn't read or process tsconfig.json. Path aliases are a compile-time feature. Alternatives include:
-
Node.js subpath imports in
package.json:
{
"imports": {
"#src/*": "./src/*"
}
}
import { db } from '#src/database.ts';
- Relative imports (the simplest approach for most cases)
Source Maps
When Node.js strips types, the line numbers in error stack traces may not match your original .ts file, since the stripped version has different line numbers (e.g., removed interfaces change the offset).
Node.js generates source maps automatically to handle this. Stack traces will point to the correct lines in your .ts source files. However, if you're using external tools that read stack traces, verify they handle source maps correctly.
Real-World Migration: From ts-node to Native
Here's a practical migration from a typical ts-node setup:
Before: The ts-node Setup
// package.json
{
"scripts": {
"dev": "ts-node --esm src/index.ts",
"start": "node dist/index.js",
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.5.0",
"ts-node": "^10.9.0",
"@types/node": "^22.0.0"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
After: Native Node.js TypeScript
// package.json
{
"scripts": {
"dev": "node --watch src/index.ts",
"start": "node src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^22.0.0"
}
}
Notice what changed:
-
ts-nodeis gone. Removed fromdevDependenciesentirely. -
devscript is justnode. Combined with--watchfor a built-in watch mode. -
startruns.tsdirectly. No more compiling todist/for development. -
typecheckis separate. Type checking becomes an explicit, opt-in step. -
buildstill usestsc. For production builds where you need declaration files or target older runtimes.
Migration Steps
Update Node.js to 22.18+ (or 25+ for stable without warnings).
Remove ts-node and tsx:
npm uninstall ts-node tsx
-
Update import extensions to use
.ts:
// Before
import { db } from './database.js';
// After
import { db } from './database.ts';
Replace enums with
as constobjects (or use--experimental-transform-types).Enable
erasableSyntaxOnlyin tsconfig (TypeScript 5.8+):
{
"compilerOptions": {
"erasableSyntaxOnly": true
}
}
This flag tells tsc to reject any non-erasable syntax (enums, parameter properties, namespaces) at compile time, ensuring your code is always compatible with Node.js type stripping. If you accidentally use a feature that Node.js can't handle, TypeScript will catch it before runtime.
Update scripts in
package.json.Add
typecheckto CI:
# GitHub Actions
- name: Type Check
run: npx tsc --noEmit
Should You Adopt This Today?
Here's a decision matrix:
| Scenario | Recommendation |
|---|---|
| New project, modern Node.js | ✅ Use native TypeScript |
| CLI tools and scripts | ✅ Perfect fit — zero build step |
| Development/prototyping | ✅ Fastest possible iteration |
| API server (Express, Fastify) | ✅ Works great, add tsc in CI |
| NestJS / TypeORM (decorators) | ⚠️ Wait — legacy decorators not supported |
| Library with declaration files | ⚠️ Still need tsc for .d.ts generation |
| Production builds for older Node.js | ❌ Keep tsc for compilation |
The Recommended Hybrid Workflow
For most projects in 2026, the optimal workflow is:
Development: node --watch app.ts (instant, no build)
Editor: TypeScript LSP (real-time type checking)
CI: tsc --noEmit (strict type verification)
Production: node app.ts (direct execution)
Type checking happens in your editor and CI pipeline. Execution happens directly. There's no build step in between unless you specifically need one (declaration files, targeting older runtimes, or bundling for browsers).
The Ecosystem Impact
Node.js native TypeScript support has ripple effects across the ecosystem:
Tools becoming less necessary:
-
ts-node— the most direct replacement; largely unnecessary for development -
tsx— the fasterts-nodealternative; same story -
esbuild/swcas dev transpilers — Node.js now handles this
Tools still essential:
-
tsc— for type checking, declaration file generation, and targeting older environments - Bundlers (Vite, webpack, Rollup) — for browser code, tree-shaking, and optimization
-
@swc/core/esbuild— for production build pipelines where you need full transformation
Frameworks adapting:
- Deno and Bun already had native TypeScript support. Node.js joining this club levels the playing field.
- NestJS is exploring TC39 decorators (which don't need type transformation) as a migration path.
- Express and Fastify work perfectly with native type stripping — no changes needed.
Looking Ahead
Node.js's native TypeScript support represents a broader trend: the blurring line between TypeScript and JavaScript. TypeScript is no longer a "compile-to-JS" language in the traditional sense. It's becoming a dialect of JavaScript that runtimes understand natively.
The TypeScript team is accelerating this convergence. TypeScript 5.8 added the --erasableSyntaxOnly flag so developers can enforce Node.js compatibility at compile time. TypeScript 6.0 Beta, released in February 2026, builds on this foundation as a transitional release. And the most ambitious change is TypeScript 7.0 (Project Corsa) — a complete rewrite of the compiler and language service in Go, targeting a 10x performance improvement. Targeted for mid-2026, Project Corsa will make tsc --noEmit so fast that the "skip type checking during development" argument may become moot entirely.
Meanwhile, the TC39 Type Annotations proposal (Stage 1) aims to make type annotations part of the JavaScript specification itself. If that proposal advances, browsers and runtimes would natively ignore type annotations — exactly what Node.js is doing today with Amaro.
We're moving toward a world where the question isn't "How do I compile TypeScript?" but rather "Why would I need to compile at all?"
For most applications, the answer is increasingly: you don't.
Conclusion
Node.js native TypeScript support isn't just a convenience feature — it's a paradigm shift. The development loop shrinks from edit → compile → run to edit → run. The dependency tree gets lighter. The mental overhead of build configuration disappears.
If you're starting a new Node.js project in 2026, there's no reason to set up a TypeScript compilation pipeline for development. Just write .ts files and run them with node. Save tsc for type checking in CI and for the rare cases where you need declaration files or compiled output.
The future of TypeScript in Node.js isn't about better compilers. It's about not needing one at all.
💡 Note: This article was originally published on the Pockit Blog.
Check out Pockit.tools for 60+ free developer utilities. For faster access, add it to Chrome and use JSON Formatter & Diff Checker directly from your toolbar.
Top comments (0)