For years, running a single TypeScript file on the backend meant pulling in a small mountain of dependencies. Before writing a line of business logic, you had to install typescript, @types/node, and an execution wrapper like ts-node or tsx—or worse, wire up a build step that spat out messy JavaScript into a dist/ folder just to test a five-line cron script.
Modern Node.js (starting experimentally in v22.6 and enabled by default in current releases like Node 22.18+ and Node 24+) eliminates that friction. Node can now execute .ts files directly from the command line using built-in type stripping.
There is a catch, though: Node is not running the TypeScript compiler under the hood. If you treat native TypeScript execution exactly like a traditional tsc build, you will quickly hit syntax errors around imports, enum declarations, and namespaces. Here is how native execution actually works and how to configure your project so it never trips you up.
How Type Stripping Works Under the Hood
When you run node server.ts, Node does not type-check your code. Instead, it uses a fast lightweight parser (powered by SWC via Amaro) to locate TypeScript-specific type annotations and replace them with whitespace before handing the code to the V8 JavaScript engine.
Why whitespace instead of deleting the characters outright? Because preserving exact character positions keeps line and column numbers intact. When your script throws an unhandled exception at line 42, the stack trace points to line 42 of your original .ts file—even without generating source maps.
A Minimal Working Example
To see this in action, create a fresh directory with a package.json configured for standard ES modules:
{
"name": "native-ts-demo",
"version": "1.0.0",
"type": "module"
}Next, create two files: a helper module pricing.ts and an entry script index.ts.
// pricing.ts
export interface LineItem {
id: string;
unitPriceInPaise: number;
quantity: number;
taxRate?: number;
}
export function calculateInvoiceTotal(items: LineItem[]): number {
return items.reduce((sum, item) => {
const subtotal = item.unitPriceInPaise * item.quantity;
const tax = subtotal * (item.taxRate ?? 0.18);
return sum + Math.round(subtotal + tax);
}, 0);
}Now import that helper inside index.ts. Pay close attention to the file extension in the import path and the type keyword on the interface import:
// index.ts
import { calculateInvoiceTotal, type LineItem } from "./pricing.ts";
const cart: LineItem[] = [
{ id: "sku-router-01", unitPriceInPaise: 249900, quantity: 2 },
{ id: "sku-cable-cat6", unitPriceInPaise: 39900, quantity: 5, taxRate: 0.12 }
];
const totalPaise = calculateInvoiceTotal(cart);
console.log(`Final Payable: INR ${(totalPaise / 100).toFixed(2)}`);Run the file directly with Node:
node index.tsOn recent Node releases, this prints Final Payable: INR 8136.88 immediately with zero packages installed in node_modules. (If you are stuck on an older Node 22 build between 22.6.0 and 22.17.0, pass the --experimental-strip-types flag).
Three Rules That Catch Developers Off Guard
Because Node strips types rather than transpiling code, three everyday TypeScript habits will break at runtime unless you adjust how you write them.
1. Always Use Explicit .ts Extensions in Local Imports
Traditional TypeScript bundlers let you write import { foo } from "./utils" or ask you to write "./utils.js" even when the source file on disk is utils.ts. Node's native module resolver does not guess file extensions. If the file on disk is named pricing.ts, your import statement must say ./pricing.ts.
2. Mark Type-Only Imports with the type Keyword
Look back at the import line in index.ts:
import { calculateInvoiceTotal, type LineItem } from "./pricing.ts";Why is type LineItem mandatory here? Because Node strips the export interface LineItem declaration from pricing.ts before V8 ever sees the file. If you write import { LineItem } from "./pricing.ts" without the type modifier, Node leaves the import untouched in the resulting JavaScript. V8 then tries to import a runtime variable named LineItem that no longer exists, crashing with a SyntaxError: The requested module './pricing.ts' does not provide an export named 'LineItem'.
3. Avoid Runtime TypeScript Syntax (Enums, Parameter Properties, Namespaces)
Pure types—like interface, type aliases, and generics—disappear cleanly when erased. However, a few legacy TypeScript features actually generate new JavaScript code during compilation:
Enums: An
enum Status { Pending, Active }compiles into an immediately invoked function expression (IIFE) object at runtime.Constructor Parameter Properties: Writing
constructor(private readonly db: Database) {}requires the compiler to injectthis.db = dbinside the constructor body.Namespaces: Non-type
namespaceblocks emit runtime closures.
By default, Node refuses to run these constructs because erasing them would change how your program behaves. Instead of an enum, use a standard JavaScript object locked with as const:
// Breaks under default type stripping:
// enum OrderState { Pending = "PENDING", Shipped = "SHIPPED" }
// Works everywhere and strips cleanly:
export const OrderState = {
Pending: "PENDING",
Shipped: "SHIPPED",
} as const;
export type OrderState = (typeof OrderState)[keyof typeof OrderState];Need legacy enums anyway? If you are migrating an existing codebase and cannot refactor every
enumright away, pass--experimental-transform-typesto Node. It enables full syntax transformation for enums and parameter properties at the cost of slightly slower startup.
The Exact tsconfig.json You Need
Even though Node executes your .ts files directly, remember that Node never checks if your types are actually valid. You could assign a number to a string variable and Node will happily strip the type annotation and run the broken logic.
You still need the TypeScript compiler (tsc --noEmit) running in your IDE and CI pipeline to catch type bugs. To stop VS Code and tsc from complaining about .ts import extensions or missing type modifiers, use this tsconfig.json:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true
}
}Each of those middle flags solves a specific problem:
Compiler Flag | Why It Matters for Native Node Execution |
|---|---|
| Tells |
| Stops your editor from throwing red squiggly errors when you write |
| Forces you to add the |
| Introduced in TypeScript 5.8+, this flags an immediate editor error if anyone tries to write an |
When Should You Still Use a Build Step?
Native type stripping is ideal for local CLI utilities, database migration scripts, internal microservices, and rapid prototyping. You can even pair it with Node's built-in file watcher—node --watch index.ts—to get instant live-reloading without installing nodemon or tsx.
However, you should still compile TypeScript to plain JavaScript when:
Publishing a package to npm: Node refuses to strip types inside
node_modulesfolders for performance and stability reasons. Published libraries must always ship compiled.jsand.d.tsdeclaration files.Using JSX or heavy path aliases: If your backend renders React SSR templates (
.tsx) or relies heavily on custom bundler features, stick with a dedicated build tool liketsup, Vite, or standardtsc.



