Programming

How to Run TypeScript Natively in Node.js Without ts-node or tsx

Stop installing ts-node and complex build pipelines for simple backend projects. Learn how Node.js executes .ts files natively via type stripping, which syntax rules you must follow, and how to set up tsconfig.json properly.

Geometric editorial illustration on a warm ivory background showing a floating code card sliding through a violet filter frame that strips away coral outline tags, leaving solid dark ink blocks inside a terminal card.

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:

json
{
  "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.

typescript
// 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:

typescript
// 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:

bash
node index.ts

On 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:

typescript
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 inject this.db = db inside the constructor body.

  • Namespaces: Non-type namespace blocks 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:

typescript
// 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 enum right away, pass --experimental-transform-types to 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:

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

noEmit: true

Tells tsc to only check types and never output compiled .js files into your folder. Required before enabling allowImportingTsExtensions.

allowImportingTsExtensions: true

Stops your editor from throwing red squiggly errors when you write import "./pricing.ts".

verbatimModuleSyntax: true

Forces you to add the type keyword when importing interfaces or types, preventing runtime missing-export crashes in Node.

erasableSyntaxOnly: true

Introduced in TypeScript 5.8+, this flags an immediate editor error if anyone tries to write an enum, namespace, or constructor parameter property that Node cannot strip.

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_modules folders for performance and stability reasons. Published libraries must always ship compiled .js and .d.ts declaration 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 like tsup, Vite, or standard tsc.