DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

Node.js Native TypeScript: The Complete Guide to Running .ts Files Without a Compiler

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This architecture has several important implications:

  1. It's fast. SWC is written in Rust and compiled to WebAssembly. The type stripping operation is orders of magnitude faster than a full tsc compilation because it only parses and strips — it doesn't resolve types, check constraints, or emit declaration files.

  2. It's built-in. Amaro ships with Node.js itself. No npm install needed. No node_modules pollution. It's part of the runtime.

  3. 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'];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Generics

function identity<T>(value: T): T {
  return value;
}

class Container<T> {
  constructor(private value: T) {}
  get(): T { return this.value; }
}
Enter fullscreen mode Exit fullscreen mode

Type Assertions and as Casts

const input = document.getElementById('name') as HTMLInputElement;
const data = JSON.parse(body) as ApiResponse;
Enter fullscreen mode Exit fullscreen mode

satisfies Operator

const config = {
  host: 'localhost',
  port: 3000,
} satisfies Config;
Enter fullscreen mode Exit fullscreen mode

Optional Chaining and Nullish Coalescing (Already JS)

// These are JavaScript syntax, not TypeScript — they work regardless
const name = user?.profile?.name ?? 'Anonymous';
Enter fullscreen mode Exit fullscreen mode

Utility Types

type ReadonlyUser = Readonly<User>;
type PartialConfig = Partial<Config>;
type UserKeys = keyof User;
// All erased at type-strip time
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

Parameter Properties

// ❌ Requires --experimental-transform-types
class User {
  constructor(public name: string, private age: number) {}
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Legacy Decorators and emitDecoratorMetadata

// ❌ Not supported — these generate runtime metadata
@Controller('/users')
class UserController {
  @Get('/:id')
  getUser(@Param('id') id: string) { ... }
}
Enter fullscreen mode Exit fullscreen mode

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('@');
  }
}
Enter fullscreen mode Exit fullscreen mode

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('@');
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// ❌ Node.js doesn't read tsconfig.json
import { db } from '@/database';
Enter fullscreen mode Exit fullscreen mode

Node.js doesn't read or process tsconfig.json. Path aliases are a compile-time feature. Alternatives include:

  1. Node.js subpath imports in package.json:
{
  "imports": {
    "#src/*": "./src/*"
  }
}
Enter fullscreen mode Exit fullscreen mode
import { db } from '#src/database.ts';
Enter fullscreen mode Exit fullscreen mode
  1. 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"
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what changed:

  1. ts-node is gone. Removed from devDependencies entirely.
  2. dev script is just node. Combined with --watch for a built-in watch mode.
  3. start runs .ts directly. No more compiling to dist/ for development.
  4. typecheck is separate. Type checking becomes an explicit, opt-in step.
  5. build still uses tsc. For production builds where you need declaration files or target older runtimes.

Migration Steps

  1. Update Node.js to 22.18+ (or 25+ for stable without warnings).

  2. Remove ts-node and tsx:

npm uninstall ts-node tsx
Enter fullscreen mode Exit fullscreen mode
  1. Update import extensions to use .ts:
// Before
import { db } from './database.js';

// After
import { db } from './database.ts';
Enter fullscreen mode Exit fullscreen mode
  1. Replace enums with as const objects (or use --experimental-transform-types).

  2. Enable erasableSyntaxOnly in tsconfig (TypeScript 5.8+):

{
  "compilerOptions": {
    "erasableSyntaxOnly": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Update scripts in package.json.

  2. Add typecheck to CI:

# GitHub Actions
- name: Type Check
  run: npx tsc --noEmit
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 faster ts-node alternative; same story
  • esbuild / swc as 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)