Run TypeScript in Node.js

TypeScript · Lesson 3 of 28 7 min read Updated

In the previous lesson you compiled TypeScript to JavaScript with tsc and then ran the JavaScript. Modern Node.js can skip that build step: it can run a .ts file directly by removing the types as it loads the file. This is called type stripping.

In this lesson you will run TypeScript files with plain node, learn exactly what type stripping can and cannot handle, and set up tsconfig.json so that the compiler warns you about anything Node.js would reject.

Running a .ts file directly

Create a file called hello.ts in a project whose package.json has "type": "module":

TypeScript
type Order = { item: string; price: number; qty: number };

const order: Order = { item: "Masala dosa", price: 90, qty: 3 };
const total: number = order.price * order.qty;

console.log(`${order.qty} x ${order.item} = ₹${total}`);

Now run it:

Terminal
node hello.ts
Output
3 x Masala dosa = ₹270

No tsc, no dist folder, no extra packages. Node.js removed the type Order = ... line and the : Order and : number annotations, then ran what was left as normal JavaScript.

Type stripping arrived in stages:

Node.js versionType stripping
22.6Added behind the --experimental-strip-types flag
22.18 and 23.6Turned on by default
24.12 and 25.2Marked stable
26 (current LTS)Stable and on by default; --experimental-transform-types removed

On Node.js 22 versions older than 22.18 you must add the flag: node --experimental-strip-types hello.ts. You can turn the feature off with --no-strip-types.

Node.js does not check types

Type stripping only removes types. It never checks them. This file has an obvious type error, yet it runs:

TypeScriptRun it yourself
const ticketPrice: number = "free"; // tsc reports an error here

function discount(price: number): number {
  return price * 0.9;
}

console.log(ticketPrice);
console.log(discount(ticketPrice));
Output
free
NaN

The Run button in this course behaves the same way: it removes the types and runs the code. So the rule for real projects is simple: Node.js runs the code, tsc checks it. Keep npx tsc --noEmit (or your editor) in your workflow, for example as an npm script that runs before every commit or deploy.

Erasable syntax only

Node.js replaces each piece of type syntax with blank spaces, so line and column numbers in error messages still match your file. This only works for syntax that can be deleted without changing what the program does. We call that erasable syntax: type annotations, type and interface declarations, generics, as assertions, import type and similar.

A few older TypeScript features generate real JavaScript code, so they cannot simply be deleted:

  • enum declarations
  • namespace blocks that contain values
  • parameter properties, such as constructor(public name: string)
  • import aliases, such as import fs = require("fs")

Node.js stops with the error ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX when it meets one of these. Decorators also fail, because the JavaScript engine itself does not support them yet. You will learn the modern replacements in Enums & Erasable Syntax and Classes & Access Modifiers.

The erasableSyntaxOnly compiler option (added in TypeScript 5.8) makes tsc report these features as errors, so you find them before Node.js does:

TypeScript
enum Coach { Sleeper, AC3 }

class Seat {
  constructor(public no: number) {}
}

namespace Rail { export const zone = "NR"; }
Output
bad.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
bad.ts(4,15): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
bad.ts(7,11): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

Imports need file extensions and import type

Node.js does not read tsconfig.json, and it does not guess file names. Two rules follow from that.

First, relative imports must include the real file extension, .ts. Second, if you import something that exists only as a type, you must say so with import type (or type inside the braces). Otherwise Node.js looks for a real export with that name and fails, because the type was stripped from the other file.

TypeScript
// fees.ts
export type Student = { name: string; fee: number; paid: number };

export function due(s: Student): number {
  return s.fee - s.paid;
}
TypeScript
// main.ts
import { due } from "./fees.ts";
import type { Student } from "./fees.ts";

const batch: Student[] = [
  { name: "Aarav", fee: 12000, paid: 12000 },
  { name: "Meera", fee: 12000, paid: 8000 },
];

for (const s of batch) {
  console.log(`${s.name} owes ₹${due(s)}`);
}
Output
Aarav owes ₹0
Meera owes ₹4000

If you remove the word type from the second import, Node.js fails with SyntaxError: The requested module './fees.ts' does not provide an export named 'Student'. With verbatimModuleSyntax turned on, tsc catches this first with error TS1484.

The module system follows the file extension: .ts files follow the "type" field in package.json (like .js files), .mts files are always ES modules and .cts files are always CommonJS. .tsx files are not supported, and Node.js refuses to strip types from files inside node_modules, so packages must still be published as JavaScript.

A tsconfig that matches Node.js

Use these settings when Node.js runs your .ts files directly. They make tsc a pure checker that agrees with Node.js:

JSON
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"]
  }
}
  • noEmit: Node.js runs the .ts files, so tsc does not need to write JavaScript.
  • module: nodenext: follow the same module rules as Node.js.
  • rewriteRelativeImportExtensions: allows ./fees.ts imports, and if you ever do emit JavaScript, rewrites them to ./fees.js.
  • erasableSyntaxOnly and verbatimModuleSyntax: report anything Node.js would reject.
  • types: ["node"]: load @types/node (install it with npm install --save-dev @types/node).

Then add "check": "tsc" and "start": "node src/main.ts" to your npm scripts.

Common mistakes

  • Thinking node app.ts checks types. It does not. Run tsc --noEmit as well.
  • Writing import { x } from "./utils". Node.js needs the full name ./utils.ts.
  • Importing a type without type. The import fails at run time because the type no longer exists.
  • Using enum or constructor(public x). These are not erasable; turn on erasableSyntaxOnly to catch them.
  • Expecting paths aliases to work. Node.js ignores tsconfig.json; use package.json imports (names starting with #) instead.

Key points

  • Node.js 22.18+ and all current versions run .ts files directly by stripping types; Node.js 26 LTS has it stable and on by default.
  • Type stripping removes types but never checks them, so keep running tsc --noEmit.
  • Only erasable syntax works: no enum, value namespaces, parameter properties, import aliases or decorators.
  • Relative imports need the .ts extension, and type-only imports need import type.
  • Use erasableSyntaxOnly, verbatimModuleSyntax, rewriteRelativeImportExtensions and module: nodenext so tsc and Node.js agree.

Exercise

Write marks.ts that exports a type Result (with name and marks) and a function grade(marks: number): string that returns "A" for 80 and above, "B" for 60 to 79 and "C" below that. Write report.ts that imports both correctly and logs the grade of three students: Kabir (85), Isha (72) and Rohan (55). Which import must use type?

Show solution
TypeScript
// marks.ts
export type Result = { name: string; marks: number };

export function grade(marks: number): string {
  if (marks >= 80) return "A";
  if (marks >= 60) return "B";
  return "C";
}
TypeScript
// report.ts
import { grade, type Result } from "./marks.ts";

const results: Result[] = [
  { name: "Kabir", marks: 85 },
  { name: "Isha", marks: 72 },
  { name: "Rohan", marks: 55 },
];

for (const r of results) {
  console.log(`${r.name}: ${grade(r.marks)}`); // Kabir: A, Isha: B, Rohan: C
}

Result exists only as a type, so it is imported with type. Run it with node report.ts.

Quick Quiz

1. What does Node.js do with type annotations when you run node app.ts?

2. Which of these works with Node.js type stripping?

3. Why must you write import type { Student } from "./fees.ts"?

4. Which compiler option reports syntax that Node.js cannot strip?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call