Node.js can execute .ts files natively. Run node app.ts, and the runtime removes the type annotations, replaces them with whitespace, and executes the remaining JavaScript. No ts-node, no tsx, no build step. This guide covers how type stripping works, which syntax survives it, how to configure tsconfig.json, and how to migrate an existing project.
Quick Takeaways
- Type stripping is stable in current Node.js. Node.js v24.12.0 (LTS) marked it stable, and the v25 documentation lists it at Stability 2.
- Node.js replaces TypeScript syntax with whitespace and performs no type checking. Run
tsc --noEmitseparately. - Only erasable syntax works.
enum,namespacewith runtime code, and constructor parameter properties need--experimental-transform-typesor a rewrite. - Use
import typefor type-only imports and write the .ts extension in relative import paths.
| Approach | Build Step | Type Checking | Startup Overhead | Non-erasable Syntax |
|---|---|---|---|---|
| Node.js type stripping | None | No (use tsc) |
Very low | Unsupported by default |
| tsx / ts-node | None (loader) | ts-node optional |
Moderate | Supported |
| tsc → JavaScript | Yes | Yes | None at runtime | Supported |
| Bundler (esbuild, swc) | Yes | No | None at runtime | Supported |
What Is Type Stripping in Node.js?
Type stripping deletes TypeScript-only syntax from a file and leaves valid JavaScript behind. It does not compile or transform your code. It blanks out type annotations, interfaces, and type aliases.
Consider this input:
const total: number = 5;
Node.js turns it into:
const total = 5;
The annotation becomes spaces. This keeps every token at its original line and column. Stack traces point to the right place without source maps, and Node.js does not generate any.
Node.js uses Amaro, a wrapper around the SWC TypeScript parser compiled to WebAssembly, to do the stripping. You never import it directly.
Which Node.js Version Do You Need?
| Node.js Version | Status |
|---|---|
| 22.6 | Introduced behind --experimental-strip-types |
| 22.18 / 23.6 | Enabled by default, with an experimental warning |
| 24.12 (LTS) | Marked stable, per the release notes |
| 25.x | Documented as Stability 2: Stable |
Use Node.js 24 LTS or newer for production work. Run node --version to check.
Syntax Breakdown: Erasable vs. Non-Erasable
The rule is simple. If deleting a piece of syntax leaves valid JavaScript with identical behavior, it is erasable. If TypeScript must generate new JavaScript to replace it, it is not.
Erasable Syntax (Works Out of the Box)
// types.ts
export interface User {
id: number;
name: string;
}
export type Role = "admin" | "editor" | "viewer";
// greet.ts
import type { User, Role } from "./types.ts";
// Parameter and return annotations are erased.
export function greet(user: User, role: Role = "viewer"): string {
return `Hello ${user.name} (${role})`;
}
// Generics and type assertions are erased too.
const items = new Map<number, User>();
const el = document.body as HTMLElement;
Erasable features include:
- Type annotations, interfaces, and type aliases
- Generics and
asassertions - The
satisfiesoperator declarestatements andabstractmodifiers- Access modifiers (
private,readonly) on class fields
Non-Erasable Syntax (Throws an Error)
These features require TypeScript to emit extra JavaScript, so type stripping rejects them:
// enum.ts - FAILS under default type stripping
enum Status {
Active,
Inactive,
}
console.log(Status.Active);
node enum.ts
# SyntaxError / ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
The error fires because an enum compiles to an object with reverse mappings. There is no whitespace substitution that produces that object.
The same restriction applies to:
- Enums (regular and
const enum) - Namespaces and modules that contain runtime code
- Constructor parameter properties (
constructor(private x: number)) - Legacy decorators and import aliases such as
import x = require()
Node.js also does not provide polyfills and will not support decorators until the language standardizes them.
To allow these features anyway, pass the transform flag:
node --experimental-transform-types app.ts
Treat that flag as a stopgap. Rewriting the code is the durable fix.
Practical Implementation
Step 1: Write and Run a TypeScript File
// server.ts
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
interface HealthPayload {
status: "ok";
uptime: number;
}
function handler(_req: IncomingMessage, res: ServerResponse): void {
const body: HealthPayload = { status: "ok", uptime: process.uptime() };
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
createServer(handler).listen(3000, () => {
console.log("Listening on http://localhost:3000");
});
node server.ts
Output:
Listening on http://localhost:3000
The server starts immediately. Node.js strips the import type line and every annotation before V8 sees the code.
Step 2: Configure tsconfig.json for Type Checking
Node.js ignores tsconfig.json at runtime. Your editor and tsc still read it, so configure it to flag syntax that Node.js cannot strip. The Node.js docs recommend TypeScript 5.8 or newer. Use these settings:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noEmit": true
}
}
What each option does:
| Option | Purpose |
|---|---|
| erasableSyntaxOnly | Makes tsc error on enums, namespaces, and parameter properties |
| verbatimModuleSyntax | Forces import type for type-only imports |
| allowImportingTsExtensions | Permits import "./x.ts" |
| rewriteRelativeImportExtensions | Rewrites .ts to .js if you later emit with tsc |
| noEmit | Uses tsc only as a checker |
The erasableSyntaxOnly flag is the most useful. It turns a runtime crash into an editor error.
Step 3: Add Scripts to package.json
{
"type": "module",
"scripts": {
"start": "node src/server.ts",
"dev": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"test": "node --test"
}
}
node --watchrestarts on file changes with no extra tooling.node --testdiscovers and runs.tstest files.npm run typecheckis your only type-checking step. Run it in CI.
Edge Cases and Gotchas
Type-Only Imports Need the type Keyword
Without the type keyword, Node.js treats the import as a value import, which causes a runtime error. The stripper works file by file. It cannot know whether User is a type or a value in another module.
// BAD: Node.js keeps this import and looks for a runtime export named User
import { User } from "./types.ts";
// GOOD: Node.js removes the whole statement
import type { User } from "./types.ts";
// GOOD: Inline form keeps the value import and strips the type specifier
import { createUser, type User } from "./user.ts";
File Extensions Are Mandatory
Node.js does not guess extensions. Write the real one.
import { greet } from "./greet.ts"; // Works
import { greet } from "./greet"; // ERR_MODULE_NOT_FOUND
Files in node_modules Are Rejected
To discourage publishing raw TypeScript packages, Node.js refuses to handle TypeScript files inside folders under a node_modules path. Publish compiled JavaScript with .d.ts files for libraries. Type stripping is intended for applications, scripts, and tooling.
No Type Checking at Runtime
This code runs without complaint:
const count: number = "not a number";
console.log(count);
Output:
not a number
Node.js erased the annotation and never compared types. Only tsc --noEmit catches this.
Run TypeScript from --eval and STDIN
Type stripping also works with --eval and STDIN.
node --input-type=module-typescript --eval "const n: number = 42; console.log(n)"
Use this for quick one-liners.
Refactoring: Bad Code vs. Good Code
Non-erasable syntax is the main migration cost. These rewrites remove it.
Replace Enums with Const Objects
Bad (anti-pattern under type stripping):
enum Direction {
Up,
Down,
Left,
Right,
}
function move(dir: Direction): void {
console.log(Direction[dir]);
}
Good (erasable and tree-shakeable):
const Direction = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
// Derive the union type from the object values.
type Direction = (typeof Direction)[keyof typeof Direction];
function move(dir: Direction): void {
console.log(dir);
}
move(Direction.Up);
Output:
UP
The as const assertion and the type alias are erased. The object stays as plain JavaScript.
Replace Parameter Properties with Explicit Fields
Bad:
class Account {
constructor(
private readonly id: string,
public balance: number,
) {}
}
Good:
class Account {
readonly #id: string;
balance: number;
constructor(id: string, balance: number) {
this.#id = id;
this.balance = balance;
}
}
The #id private field is native JavaScript, so it also gives you true runtime privacy.
Replace Namespaces with ES Modules
Bad:
namespace Validators {
export function isEmail(value: string): boolean {
return value.includes("@");
}
}
Good:
// validators.ts
export function isEmail(value: string): boolean {
return value.includes("@");
}
import * as Validators from "./validators.ts";
Validators.isEmail("dev@example.com");
Type Stripping vs. tsx vs. tsc
| Criteria | Type Stripping | tsx | tsc + node |
|---|---|---|---|
| Install required | None | Dev dependency | Dev dependency |
| Supports enum | No (flag needed) | Yes | Yes |
| Reads tsconfig paths | No | Yes | Yes (with extra tooling) |
| Type safety | Manual tsc --noEmit |
Manual | Built in |
| Best for | Scripts, servers, CI tasks | Legacy codebases | Published libraries |
| Learning curve | Low | Low | Medium |
Choose type stripping for new applications. Choose tsx when you depend on paths aliases or non-erasable features and cannot refactor yet. Choose tsc when you publish a package.
Migration Checklist
- Upgrade to Node.js 24 LTS or newer.
- Upgrade TypeScript to 5.8+ and set
erasableSyntaxOnly: true. - Run
tsc --noEmitand fix every reported enum, namespace, and parameter property. - Add
typeto every type-only import (verbatimModuleSyntaxenforces this). - Add
.tsextensions to all relative imports. - Replace
pathsaliases with thepackage.jsonimports field (#utils/*). - Swap
ts-nodeortsxin scripts for plainnode. - Keep
tsc --noEmitin CI as the type gate.
Here is the imports field replacement for path aliases:
{
"imports": {
"#utils/*": "./src/utils/*.ts"
}
}
import { slugify } from "#utils/slugify";
Strategic FAQ
Does Node.js type check TypeScript files?
No. Node.js removes types and runs the remaining JavaScript. It does not verify them. Run tsc --noEmit in your editor and CI to catch type errors.
Which Node.js version supports running TypeScript natively?
Node.js 22.6 added it behind --experimental-strip-types. It became default behavior in later 22.x and 23.x releases. Node.js v24.12.0 (LTS) marked type stripping as stable. Use Node.js 24 LTS or newer.
Why does Node.js throw an error on my TypeScript enum?
An enum generates runtime JavaScript, and type stripping only erases syntax. Node.js errors on features that replace TypeScript syntax with new JavaScript unless --experimental-transform-types is passed. Replace the enum with an as const object, or enable the flag.
Can I use type stripping for npm packages?
No. Node.js refuses .ts files under node_modules. Compile to JavaScript and ship .d.ts declarations when you publish.




