TypeScript in Node.js

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example was run on all three lines unless the text says otherwise. TypeScript itself is not part of Node.js, so the TypeScript versions named here (5.9.3, 6.0.3 and 7.0.2, the native compiler that is latest on npm today) were also installed and tried. The stability levels covered: native type stripping is Stability 2 (Stable) in the Node.js 26 and 24 documentation (Stable since v25.2.0 and v24.12.0) and Stability 1.2 (Release candidate) in the Node.js 22 documentation; process.features.typescript and module.stripTypeScriptTypes() are Stability 1.2 (Release candidate); the --experimental-transform-types flag was Experimental and was removed in v26.0.0. This page covers the runtime side of TypeScript, what node file.ts does and what it refuses to do. The language itself is the subject of the TypeScript Reference, and the compiler options in depth are in tsconfig and Compiler Options. The running scenario is the Bookshelf application of this section, described in Introduction & Architecture.

The stability and version map

Table 1. APIs and flags covered on this page
API or flag Stability (Node.js 26 docs) Version notes

Type stripping of .ts, .mts and .cts files

2 Stable

Added in v22.6.0 behind --experimental-strip-types. Enabled by default in v23.6.0 and v22.18.0. No experimental warning since v24.3.0 and v22.18.0. Stable in v25.2.0 and v24.12.0. The Node.js 22 documentation still labels it 1.2 Release candidate.

--no-strip-types

2 Stable

Renamed from --no-experimental-strip-types in v25.2.0 and v24.12.0. Observed: Node.js 24 and 26 accept both spellings, Node.js 22.23.3 accepts only the old one.

--experimental-transform-types

Removed

Added in v22.7.0, removed in v26.0.0 (node: bad option on Node.js 26). Still accepted on 24 and 22 with an ExperimentalWarning.

--input-type=module-typescript and commonjs-typescript

Documented under --input-type

Added in v23.6.0 and v22.18.0, for --eval and standard input.

process.features.typescript

1.2 Release candidate

Added in v23.0.0 and v22.10.0. Value "strip" by default and false with --no-strip-types. The "transform" value was removed in v26.0.0.

module.stripTypeScriptTypes() (node:module)

1.2 Release candidate

Added in v23.2.0 and v22.13.0. The transform and sourceMap options were removed in v26.0.0. Observed: it still prints an ExperimentalWarning on all three lines.

ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX

Error code

Added in v23.7.0 and v22.14.0.

ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING

Error code

Added in v22.6.0.

import.meta.dirname and import.meta.filename in .mts files

Stable

Work the same as in .mjs files.

What native type stripping is

TypeScript is JavaScript plus a type syntax: annotations, interfaces, type aliases, generics, as assertions. A browser or a JavaScript engine cannot read that extra syntax, so for years the only way to run TypeScript on Node.js was to convert it to JavaScript first (tsc, Babel, esbuild, SWC) or to let a runner do it on the fly (ts-node, tsx). Native type stripping removes that step for the common case. Node.js parses the .ts file, blanks out the type-only syntax and runs what is left. It never type checks: a file whose types are completely wrong runs, because the types are gone before V8 sees the code.

Under the hood the parser is Amaro, a wrapper around a WebAssembly port of the SWC TypeScript parser, and it is bundled with Node.js (process.versions.amaro is 1.1.11 on Node.js 26.10.0 and 24.21.0 and 1.1.8 on 22.23.3). The key design choice is that the removed code is replaced by whitespace, not deleted. Columns and lines of the remaining code do not move, so stack traces already point at the right place in the .ts file and no source maps are needed. This is also why the feature is called "strip-only": anything that would require generating JavaScript (an enum becomes an object, a parameter property becomes an assignment) is not supported.

flowchart LR A["main.ts
types and JavaScript"] --> B["Amaro (SWC parser)
types replaced by spaces"] B --> C["V8
runs plain JavaScript"] B -. "needs code generation" .-> D["ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX"] T["tsc --noEmit
(separate step)"] -. "reports type errors" .-> A

Running a .ts file

Type stripping has been on by default since Node.js 23.6.0 (so on every 24.x and 26.x release) and 22.18.0, so there is nothing to enable: a .ts file that contains only erasable syntax runs with node file.ts. Versions 24.3.0 and 22.18.0 only dropped the ExperimentalWarning that the earlier 24.x releases still printed. The first example is the Bookshelf domain model, a module of types and functions, and an entry point that imports it.

The domain module (src/book.ts)
export interface Book {
  readonly isbn: string;
  title: string;
  pages?: number;
}

export type Shelf = Map<string, Book>;

export function addBook(shelf: Shelf, book: Book): Shelf {
  shelf.set(book.isbn, book);
  return shelf;
}

export function titles(shelf: Shelf): string[] {
  return [...shelf.values()].map((b) => b.title).sort();
}
The entry point (src/main.ts)
import { addBook, titles, type Book, type Shelf } from './book.ts';

const shelf: Shelf = new Map();
const first: Book = { isbn: '978-0-00-000001-1', title: 'Streams in Practice' };
const second = { isbn: '978-0-00-000002-8', title: 'Event Loop Notes', pages: 120 } satisfies Book;

addBook(shelf, first);
addBook(shelf, second);
console.log(titles(shelf));
console.log(typeof process.features.typescript, process.features.typescript);
Running it
node src/main.ts
Output (identical on Node.js 26.10.0, 24.21.0 and 22.23.3, with no warning)
[ 'Event Loop Notes', 'Streams in Practice' ]
string strip

The package.json of this project contains "type": "module" (the next section explains why that matters). Three details in the code are the rules of the game: the import specifier is ./book.ts, the real file name including its extension, because Node.js resolves files and does not guess; the types are imported with the type keyword; and satisfies is erased like any other type operator, while the object keeps its precise inferred type for the compiler.

A .ts file works as an entry point in all the places that accept a script. node --test discovers and runs *.test.ts files (see Test Runner), a Worker can be started from a .ts file, and a script run with node --run or npm run can start node file.ts (see Running Scripts, CLI & REPL). The worker case was run on all three lines; the worker file has to be named in full.

A worker that is itself a TypeScript file (w.ts)
import { Worker, isMainThread, parentPort } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url));
  worker.on('message', (m: string) => { console.log('from worker:', m); });
} else {
  const msg: string = 'typed worker';
  parentPort?.postMessage(msg);
}

Running node w.ts printed from worker: typed worker on Node.js 26, 24 and 22.23.3. More on workers is in Worker Threads.

What is erased, and what is not

Type stripping only works for syntax that can be removed without changing the JavaScript that remains. The official page lists what falls outside that line; every row below was run on Node.js 26 and 22.23.3 (the error is the same on both).

Table 2. TypeScript syntax and type stripping
Syntax Result with type stripping Erasable replacement

Type annotations, interface, type aliases, generics, as, satisfies, import type, export type

Erased. Runs.

Nothing to do.

enum

ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: "TypeScript enum is not supported in strip-only mode"

A const object with as const and a derived union type.

Parameter properties (constructor(private name: string))

ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX

An explicit field and an assignment in the constructor.

namespace or module that contains runtime code

ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX

An ES module, or a plain object. A namespace that only holds types is accepted.

Import aliases (import x = require() style, import a = N.b)

Not supported (listed by the official page)

import / require, or a const.

Decorators

Parse error (SyntaxError), because Node.js does not polyfill a TC39 Stage 3 proposal

Plain function calls, or a runner or compiler that supports them.

.tsx files

ERR_UNKNOWN_FILE_EXTENSION

Use a runner or compile with tsc or a bundler.

Options that live in tsconfig.json (paths, target down-levelling, JSX transform)

Ignored: Node.js never reads tsconfig.json

Subpath imports (#name), a build step or a runner.

The three most common stumbles, as the shell shows them. The enum case is first.

A file that uses an enum (bad/enum.ts)
enum Status { Draft, Published }
console.log(Status.Published);
Output on Node.js 26 (trimmed; 24 and 22 print the same error)
file:///.../bad/enum.ts:1
enum Status { Draft, Published }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
    at parseTypeScript (node:internal/modules/typescript:57:40)

The same file that is rewritten with erasable constructs behaves identically at run time and also satisfies tsc with erasableSyntaxOnly (checked on all three lines):

Erasable replacements for an enum, a parameter property and a namespace (src/erasable.ts)
// enum -> const object plus a derived union type
export const Status = { Draft: 'draft', Published: 'published' } as const;
export type Status = (typeof Status)[keyof typeof Status];

// parameter property -> explicit field and assignment
export class Reader {
  readonly name: string;
  constructor(name: string) {
    this.name = name;
  }
  greet(): string {
    return `hi ${this.name}`;
  }
}

// namespace with values -> a plain object (or a module)
export const Shelves = { count: 1 };

const s: Status = Status.Published;
console.log(s, new Reader('Ada').greet(), Shelves.count);
Output on Node.js 26, 24 and 22
published hi Ada 1

The Status constant and the Status type share a name on purpose: a value and a type live in different namespaces of the language, so one identifier can be both, and that is the usual substitute for an enum. The trade-offs of this pattern are discussed in Enums & Literal Alternatives, and the decorator situation in Decorators & Metadata. For .tsx and JSX see JSX & Frameworks.

import type and verbatimModuleSyntax

Because Node.js looks at one file at a time, it cannot know whether Book in import { Book } from './book.ts' is a type (to be erased) or a value (to be kept). A compiler with the whole program in view would work it out and drop the import; the stripper does not. An import that is not marked as type-only is kept, and at run time the module has no export of that name.

A type imported without the type keyword (bad/value-import.ts)
import { Book } from '../src/book.ts';
const b: Book = { isbn: 'x', title: 'y' };
console.log(b.title);
Output on Node.js 26 and 22
import { Book } from '../src/book.ts';
         ^^^^
SyntaxError: The requested module '../src/book.ts' does not provide an export named 'Book'

The fix is the type modifier, either on the whole statement (import type { Book } from './book.ts') or on the individual specifier (import { addBook, type Book } from './book.ts', as in src/main.ts above). The same applies to re-exports: use export type { Book } from './book.ts'. The compiler option verbatimModuleSyntax makes tsc enforce exactly this rule, so the editor reports the mistake before Node.js does; with it enabled, tsc printed TS1484: 'Book' is a type and must be imported using a type-only import for the file above.

Extensions and the module system

Type stripping adds three extensions that mirror the JavaScript ones, and it does not convert between module systems. The module system of a .ts file is decided the same way as for a .js file.

Table 3. How Node.js treats TypeScript file extensions
Extension Module system Observed on Node.js 26, 24 and 22.23.3

.mts

Always an ES module, like .mjs

import works, require is undefined, import.meta.filename and import.meta.dirname are available.

.cts

Always CommonJS, like .cjs

require, module and __filename are available; require('./a.cts') loads another .cts file, with the extension written.

.ts

Decided by the nearest package.json "type" field, like .js

"type": "module" runs it as ESM. No "type" field: ESM syntax is detected (the file is parsed as CommonJS first and parsed again as a module) and Node.js prints a MODULE_TYPELESS_PACKAGE_JSON warning. An explicit "type": "commonjs" with import statements fails with "Cannot use import statement outside a module".

.tsx

Not supported

ERR_UNKNOWN_FILE_EXTENSION.

The "type": "commonjs" row is a trap: npm init -y writes that field into a new package.json, so a fresh project that follows the examples above fails until the field is changed to "module". Specifiers always carry their extension: import './file.ts', never import './file', and for backward compatibility require('./file.ts') too. A relative import without the extension fails with ERR_MODULE_NOT_FOUND on all three lines (checked), exactly as import './file' does in a .js file. The module systems themselves, and how they interoperate, are covered in ECMAScript Modules, CommonJS Modules and ESM & CommonJS Interop.

Checking the module system from inside a .mts file (b.mts)
import { basename } from 'node:path';
const n: string = basename(import.meta.filename);
console.log('mts file:', n, typeof require);

Running node b.mts printed mts file: b.mts undefined on all three lines.

Flags, other inputs and diagnostics

Table 4. Controlling type stripping
Option Effect

--no-strip-types

Turns type stripping off. A .ts entry point then fails with ERR_UNKNOWN_FILE_EXTENSION (checked on 26, 24 and 22). Use --no-experimental-strip-types on Node.js 22, where the new name is unknown (bad option).

--experimental-strip-types

Needed on Node.js releases before 22.18.0 (and before 23.6.0 on the 23 line), where stripping is not on by default. Harmless on later releases: on 22.23.3, 24.21.0 and 26.10.0 it runs the file without a warning.

--experimental-transform-types

Transformed enums, namespaces and parameter properties on Node.js 22 and 24, printing an ExperimentalWarning (checked: the enum file printed 1 on both). It does not exist on Node.js 26.

--input-type=module-typescript, --input-type=commonjs-typescript

Select the module system for --eval and standard input when the code contains types. Not available together with --no-strip-types.

Type syntax from the command line and from a pipe
node --input-type=module-typescript -e 'const n: number = 42; console.log(n)'
echo 'const s: string = "stdin ts"; console.log(s)' | node --input-type=commonjs-typescript

Both commands printed their value (42, stdin ts) on Node.js 26 and 22. Observed, and not promised by the documentation: node -e 'const n: number = 42; console.log(n)' without --input-type also ran on Node.js 26 and 22.23.3. The documentation says TypeScript syntax is unsupported in the REPL, with --check and with inspect; node --check src/main.ts exited with status 0 in a test, so do not rely on --check to report anything for a .ts file, and use tsc --noEmit instead.

Stack traces keep the right line and column without source maps, because types are replaced by spaces and not removed:

A typed function that throws on line 3 (full/stack.ts)
function parse(input: string): number {
  const value: number = Number(input);
  if (Number.isNaN(value)) throw new TypeError(`not a number: ${input as string}`);
  return value;
}
console.log(parse('42'));
console.log(parse('abc'));
Output on Node.js 26 (trimmed; 22 prints the same locations)
42
file:///.../full/stack.ts:3
  if (Number.isNaN(value)) throw new TypeError(`not a number: ${input          }`);
                                 ^

TypeError: not a number: abc
    at parse (file:///.../full/stack.ts:3:34)

Note the blanks inside the template literal in the echoed source line: that is the as string assertion, replaced by spaces.

Stripping from code: module.stripTypeScriptTypes()

The same stripper is available as a function, which is useful for tools that need to hand TypeScript to vm or to generate JavaScript for a snippet. It returns the code with the types blanked out, and throws the same ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX for syntax that needs code generation.

Stripping types with node:module (strip-demo.mjs)
import { stripTypeScriptTypes } from 'node:module';

const source = 'const price: number = 10;\nconst label = (n: number): string => `#${n}`;';
const js = stripTypeScriptTypes(source);
console.log(JSON.stringify(js));
console.log(js.length === source.length, js.split('\n').length === source.split('\n').length);

try {
  stripTypeScriptTypes('enum Level { Low, High }');
} catch (err) {
  console.log(err.code);
}
Output on Node.js 26, 24 and 22 (an ExperimentalWarning follows on stderr)
"const price         = 10;\nconst label = (n        )         => `#${n}`;"
true true
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX

The result has exactly the length and line count of the input. The documentation warns that the exact output is not stable across Node.js versions, because the parser changes; do not compare it to stored strings. The transform mode that Node.js 22 and 24 documented, and the sourceMap option, were removed in v26.0.0.

No type stripping in node_modules

Node.js refuses to strip types from any file under a node_modules directory. The reason given in the documentation is to discourage package authors from publishing TypeScript source, which would push the cost of parsing it onto every consumer and tool. The failure is explicit.

Importing a package that ships a .ts entry point
Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported for files under node_modules, for "file:///.../node_modules/rawts/index.ts"

That is what all three lines printed for import { hi } from 'rawts' when node_modules/rawts/index.ts existed, while the identical package with a .js entry point loaded. The consequence is simple: packages you consume must ship JavaScript, and packages you publish must too (see Publishing a TypeScript package).

One case that looks like an exception is not one. In an npm workspace (a monorepo) the packages are symlinked into node_modules, and Node.js resolves a symlink to its real path, which is outside node_modules. A workspace package whose exports points at a .ts file therefore works.

A workspace package that is consumed as TypeScript source (packages/shared and apps/api)
{
  "name": "@bookshelf/shared",
  "version": "1.0.0",
  "type": "module",
  "exports": "./index.ts"
}
packages/shared/index.ts and apps/api/main.ts
export const label = (n: number): string => `book-${n}`;
import { label } from '@bookshelf/shared';
console.log(label(7));

After npm install in the root (which links node_modules/@bookshelf/shared to packages/shared), node apps/api/main.ts printed book-7 on Node.js 26, 24 and 22.23.3. This is observed behaviour that follows from how symlinks are resolved, so it can change if the check changes. It is good for internal packages, and it does not make a published package acceptable: once installed from the registry the package lives under node_modules. Workspaces are covered in Workspaces & Publishing.

Type checking: tsc --noEmit

Running a file with node and checking it with tsc are different jobs. Node.js strips and runs; the TypeScript compiler reads the whole program and reports errors. The recommended split, which the Learn article describes, is to run Node.js directly for quick feedback and to run tsc --noEmit in a separate command or in CI. --noEmit tells the compiler to only check and write nothing.

Installing the compiler and the Node.js type declarations
npm install --save-dev typescript @types/node

The tsconfig.json that the official documentation recommends for type stripping is below. Node.js does not read it; the editor and tsc do, so that they judge the code the way Node.js will run it.

tsconfig.json for code that is run directly by Node.js
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "types": ["node"]
  },
  "include": ["src"]
}
Table 5. What each option does here
Option Purpose

module: nodenext

Makes the compiler follow Node.js’s rules: the "type" field decides ESM or CommonJS per file, exports maps are honoured, relative imports need extensions. (moduleResolution follows from it.)

rewriteRelativeImportExtensions

Allows import './book.ts' and, when you emit JavaScript, rewrites the extension to .js in the output. It arrived in TypeScript 5.7. Without it, tsc would reject a .ts extension in an import, or you would have to enable allowImportingTsExtensions, which only works together with noEmit.

erasableSyntaxOnly

Makes tsc report every construct that type stripping cannot run (enums, parameter properties, namespaces with values, import aliases). It arrived in TypeScript 5.8.

verbatimModuleSyntax

Requires import type and export type for type-only imports and exports, matching what the stripper needs, and keeps import syntax faithful to the module setting.

target: esnext

Node.js runs the newest syntax the engine supports, so nothing needs down-levelling. Choose a lower target only for a build you publish.

noEmit

Optional: for scripts that are only executed, not distributed. Leave it out, or override it, for a package you compile.

strict, types

strict turns on the strict family of checks. types: ["node"] loads the Node.js declarations (see the next section).

The official page recommends TypeScript 5.8 or newer for these settings; the Learn article says 5.7, which is where rewriteRelativeImportExtensions appeared, but erasableSyntaxOnly needs 5.8. With this file, tsc --noEmit accepts the Bookshelf code, and it reports the problems that Node.js would hit or silently ignore. A deliberately broken file (src/bad.ts) produced these diagnostics with TypeScript 7.0.2:

A file with four mistakes and the compiler output
import { Book } from './book.ts';
enum Level { Low, High }
class R { constructor(private n: string) {} }
const x: number = 'nope';
console.log(Book, Level, R, x);
src/bad.ts(1,10): error TS1484: 'Book' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
src/bad.ts(2,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
src/bad.ts(3,23): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
src/bad.ts(4,7): error TS2322: Type 'string' is not assignable to type 'number'.
src/bad.ts(5,13): error TS2693: 'Book' only refers to a type, but is being used as a value here.

Only the last two are type errors in the strict sense; the first three are the exact things that would have crashed at run time (the stripper cannot run the enum and the parameter property, and Book would not exist as a value). The configuration turns Node.js’s own limits into compile-time errors, which is its whole point. Put the check next to the run command in package.json, so that people and CI use the same names:

package.json scripts (checked with npm run and node --run)
{
  "scripts": {
    "start": "node src/main.ts",
    "typecheck": "tsc --noEmit",
    "test": "node --test 'src/**/*.test.ts'"
  }
}

npm run typecheck exited with status 0 on the Bookshelf project, and node --run start printed the same output as node src/main.ts. The quoted glob works because the test runner expands patterns itself on Node.js 22 and later. For more on node --run see Running Scripts, CLI & REPL.

Which TypeScript version

The recommended file above worked unchanged on TypeScript 5.9.3 and 7.0.2 (each tried in a scratch project). Two defaults changed with TypeScript 6.0, and the TypeScript 7.0 announcement (the native compiler, released on 2026-07-08) carries the same defaults forward. Both bite Node.js projects.

Table 6. Compiler behaviour that differs by TypeScript version (observed with 5.9.3, 6.0.3 and 7.0.2)
Behaviour 5.9.3 6.0.3 7.0.2

@types/node loaded automatically when types is not set

Yes

No: TS2591: Cannot find name 'process'

No: the same error

outDir without rootDir when sources are in src/

Layout inferred (dist/index.js)

Documented: rootDir is the folder of tsconfig.json, so set it

TS5011: the common source directory is ./src, set rootDir

erasableSyntaxOnly, rewriteRelativeImportExtensions, verbatimModuleSyntax

Available

Available

Available

The practical rules: always write "types": ["node"] (the file above does), and always write "rootDir": "src" next to outDir in a build configuration. Both are harmless on TypeScript 5. The 7.0 announcement also says that the new tsc ships in the typescript package, and that a compatibility package, @typescript/typescript6, provides a tsc6 executable and the 6.0 API for tools that still import typescript directly (typescript-eslint is the example it names).

@types/node

@types/node is the package of type declarations for the Node.js globals and built-in modules. It lives in the DefinitelyTyped repository, not in Node.js, and it is what lets tsc and your editor understand process, Buffer, node:fs, node:http and the rest. It is a development dependency, because stripping erases every reference to it; it never reaches run time.

Matching the declarations to the Node.js you run
npm install --save-dev @types/node@24

Its major version follows the Node.js major (on 2026-10-04 npm resolves @types/node@26 to 26.6.4, @types/node@24 to 24.19.1 and @types/node@22 to 22.20.5), so install the major that matches your minimum supported Node.js, and the compiler will flag APIs that your oldest supported release does not have. Without it, tsc reports TS2591: Cannot find name 'process', Cannot find name 'node:fs' and Property 'filename' does not exist on type 'ImportMeta' (checked: removing the package from a scratch project produced exactly those errors). A types-only package also means that import type is how you refer to a Node.js type at run time without cost, for example import type { Dirent } from 'node:fs'.

When native stripping is not enough: transpiling and runners

Native stripping is deliberately small. If the project needs enums, decorators, JSX, tsconfig paths, or down-levelling, there are three other routes. The Learn section lists them as separate articles.

Table 7. Ways of running TypeScript on Node.js
Route What it does Type checks? Choose it when

Native stripping (node file.ts)

Blanks out types, runs the file

No (use tsc --noEmit)

Scripts, services, tests and tools that stay within erasable syntax. No extra dependency, no build.

Compile with tsc (or SWC, esbuild, a bundler), then run the .js

Converts every supported feature to JavaScript, writes output files

tsc: yes, while compiling. SWC and esbuild: no.

You publish a package, need old targets, decorators or JSX, or deploy a bundle.

A runner such as tsx

Loads TypeScript on the fly (esbuild based); reads tsconfig.json

No

You need non-erasable syntax or paths during development and do not want a build step.

Learn also lists ts-node

Runs TypeScript with type checking unless transpileOnly is set

Yes by default

An existing project that already uses it; check its maintenance state before starting a new one.

tsx is installed as a development dependency and used either as a command or as a loader registered with --import. Both forms were run on all three lines, on a file that stripping alone rejects (an enum and a parameter property):

A file that needs more than type stripping (full/rich.ts)
enum Status { Draft, Published }

class Reader {
  constructor(private readonly name: string) {}
  greet(): string {
    return `hi ${this.name}`;
  }
}

const bad: number = 'not a number' as unknown as number; // no type check at run time
console.log(Status[Status.Published], new Reader('Ada').greet(), typeof bad);
Running it with tsx
npm install --save-dev tsx
npx tsx full/rich.ts
node --import=tsx full/rich.ts
Output of both commands on Node.js 26, 24 and 22
Published hi Ada string

As the Learn page says, tsx does not type check: keep tsc --noEmit in the loop. Use the runner as the exception, not the default; a codebase that sticks to erasable syntax has the advantage that it runs with plain node in production images, in CI and in any tool that spawns node, with nothing to install. The --import flag preloads a module before the entry point, which is how the runner hooks into Node.js; the flag is listed in CLI Options, Environment & Config File.

Compiling with tsc

Compiling is the classic route and the right one for a published package. The build configuration extends the checking one and turns emit back on. rewriteRelativeImportExtensions is what makes the same source work in both worlds: the source keeps ./book.ts (which Node.js can run), and the emitted JavaScript says ./book.js.

tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "target": "es2022",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true
  },
  "exclude": ["src/**/*.test.ts"]
}
Compile and inspect the output
npx tsc -p tsconfig.build.json
cat dist/index.js
Output of the compiled entry point (TypeScript 5.9.3 and 7.0.2)
export { addBook, titles } from './book.js';

(TypeScript 5.9.3 writes the specifier with double quotes, TypeScript 7.0.2 with single quotes; the content is the same.) For a tree of many files, project references and incremental builds, see Project References & Build. The matching .d.ts files in dist are covered in the publishing section.

Migrating old import specifiers

Before rewriteRelativeImportExtensions and native stripping, TypeScript code was written with the extension of the output: import './a.js' in a file whose source is a.ts, or no extension at all. Node.js now needs the real extension of the source, so such a code base has to be migrated once. The Node.js project publishes a codemod for it, @nodejs/correct-ts-specifiers, run through npx codemod. It rewrites relative specifiers to the file that exists, only when it is unambiguous, and the official page asks you to commit first because it edits source files. It needs --experimental-import-meta-resolve in NODE_OPTIONS.

Running the codemod (on a git-tracked copy)
NODE_OPTIONS="--experimental-import-meta-resolve" npx codemod @nodejs/correct-ts-specifiers
Before and after, from the run on a two-file test project
- import { a } from './a.js';
- import { u } from './util';
+ import { a } from './a.ts';
+ import { u } from './util/index.ts';

After the run the project executed with node src/main.ts on Node.js 26 (output 1 u). The tool printed a harmless warning about its alias loader (no paths in the tsconfig.json). It does not check that an imported module exports the name you import, and it does not add missing type keywords: the page recommends a linter fixer (Biome, oxlint or typescript-eslint rules) for that, run after the codemod. It also skips ambiguous cases, such as a .js and a .ts file with the same base name, and logs an error for each. tsc needs rewriteRelativeImportExtensions when it compiles such code, and allowImportingTsExtensions when it only type checks.

Path aliases

tsconfig.json paths are not read by Node.js and are not transformed, so an import like @app/book.ts fails at run time (ERR_MODULE_NOT_FOUND on all three lines), and tsc also warns, in TypeScript 7.0.2, that the non-relative import will not be rewritten (TS2877). The closest feature that Node.js supports is the imports field of package.json (subpath imports), where every name must start with #.

Subpath imports as the Node.js-native alias (package.json and src/app.ts)
{
  "name": "alias-demo",
  "private": true,
  "type": "module",
  "imports": {
    "#book": "./src/book.ts"
  }
}
import { titles, type Shelf } from '#book';

const shelf: Shelf = new Map([['1', { isbn: '1', title: 'Alias' }]]);
console.log(titles(shelf));

node src/app.ts printed [ 'Alias' ] on Node.js 26, 24 and 22.23.3, and tsc --noEmit with module: nodenext accepted the #book import. Conditions, wildcards and the rules for imports are in package.json: exports & imports. Note that the mapping points at a .ts file, which is correct for running the source and for type checking; a published package maps to compiled output instead.

Publishing a TypeScript package

Everything on the publishing page applies, with one extra rule that follows from the node_modules restriction: publish JavaScript plus declaration files, not TypeScript source. The Learn article puts it as: fields like main operate on published content, so they point to .js files, while fields like scripts.test operate on source code and use .ts. It also recommends type checking in CI as a separate job (types are platform independent, so run once), and running tests on a matrix of Node.js versions. The following small package, bookshelf-shelf, re-exports the Bookshelf domain module. It was packed, installed into a consumer project and run.

Package layout
bookshelf-shelf/
|- src/
|  |- book.ts
|  |- book.test.ts
|  `- index.ts
|- package.json
|- tsconfig.json          (checking: noEmit, as above)
`- tsconfig.build.json    (emit: shown in the previous section)
src/index.ts
export { addBook, titles } from './book.ts';
export type { Book, Shelf } from './book.ts';
package.json
{
  "name": "bookshelf-shelf",
  "version": "1.0.0",
  "type": "module",
  "files": ["dist"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "engines": { "node": ">=22" },
  "scripts": {
    "test": "node --test 'src/**/*.test.ts'",
    "types:check": "tsc --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "prepack": "npm run build"
  },
  "devDependencies": {
    "@types/node": "^24.0.0",
    "typescript": "^5.9.0"
  }
}

The pieces, and why each is there:

  • "files": ["dist"] is an allow-list, so tests and sources stay out of the tarball (the Learn article uses .npmignore for the same job; an allow-list fails safe).

  • exports with a types condition first, then default, points consumers and the compiler at the output. Put types before the runtime condition, because conditions are matched in order.

  • prepack runs before npm pack and npm publish, so the build cannot be forgotten. The Learn article uses the same script name; node --run does not run pre and post scripts, so do not use it for the packing step.

  • engines documents the oldest Node.js you support; the Learn article advises compiling to support all current LTS lines, since the consumer’s version is unknown. Here the es2022 target runs on Node.js 22 and later.

  • @types/node is a development dependency. It is only needed if your public declarations refer to Node.js types; in that case consider listing it as a peer dependency.

Checking, testing, packing
npm run types:check
npm test
npm pack

npm run types:check and npm test passed on Node.js 26, 24 and 22 (one test, one pass). npm pack ran prepack first and listed the seven files below in the tarball (about 1 kB), with no tests and no .ts sources:

dist/book.d.ts
dist/book.d.ts.map
dist/book.js
dist/index.d.ts
dist/index.d.ts.map
dist/index.js
package.json

A consumer installed that tarball, imported it from a .ts file and ran it with plain node on Node.js 26 and 22.23.3, and type checked it with TypeScript 5.9.3 and @types/node@24 under module: nodenext with skipLibCheck off:

The consumer (app.ts)
import { addBook, titles, type Book, type Shelf } from 'bookshelf-shelf';

const shelf: Shelf = new Map();
const book: Book = { isbn: '1', title: 'Packed' };
addBook(shelf, book);
console.log(titles(shelf));

The program printed [ 'Packed' ] and tsc --noEmit reported no errors. One detail worth knowing, observed: the generated .d.ts files keep the ./book.ts specifier that the source used (only the .js output is rewritten), and consumers still type check; if a consumer uses an older TypeScript, test against it. A declaration file can also be written by hand for plain JavaScript, see Declaration Files. The release flow (versioning, provenance, access) is in Workspaces & Publishing and npm & Package Management, and the interplay of exports conditions with ESM and CommonJS consumers in package.json: exports & imports.

Choosing an approach

Table 8. A decision guide
Situation Use

A script, a CLI, an internal service or a test suite for Node.js 22.18 or later

Native stripping, the recommended tsconfig.json with erasableSyntaxOnly, and tsc --noEmit in CI.

The same project, but a few files already use enums or parameter properties

Rewrite them with erasable constructs (a small change, shown above), or run those files through a runner such as tsx.

A library or package for other people

Compile with tsc to dist, publish JavaScript and .d.ts files, and keep the source in src with rewriteRelativeImportExtensions.

A web or React project with JSX, or code that uses decorators heavily (NestJS, for example)

The framework’s own toolchain (a bundler, SWC or tsc). NestJS compiles with its CLI; see NestJS Reference. Native stripping does not apply.

Node.js older than 22.18

Upgrade (see Release Lines & LTS), or use --experimental-strip-types on 22.6 to 22.17, or a runner.

What changed recently

Table 9. Type stripping and its neighbours, by release
Release Change

v22.6.0 (2024)

--experimental-strip-types added (Experimental).

v22.7.0

--experimental-transform-types added.

v22.13.0, v23.2.0

module.stripTypeScriptTypes() added.

v22.14.0, v23.7.0

ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX added.

v22.18.0, v23.6.0

Type stripping enabled by default, so no flag is needed; --input-type gains module-typescript and commonjs-typescript (v23.6.0 and v22.18.0).

v22.18.0, v24.3.0

The experimental warning is no longer printed.

v24.12.0, v25.2.0

Type stripping becomes Stable; the flag is renamed to --no-strip-types.

v26.0.0

--experimental-transform-types removed; process.features.typescript loses the "transform" value; module.stripTypeScriptTypes() loses transform and sourceMap.

TypeScript 5.7 / 5.8

rewriteRelativeImportExtensions (5.7) and erasableSyntaxOnly (5.8) added, built to match Node.js’s behaviour.

TypeScript 6.0, 7.0

types defaults to an empty list and rootDir defaults to the tsconfig.json folder; 7.0 (2026-07-08) is the native compiler and is latest on npm.

Two consequences for existing projects. First, the escape hatch for enums and parameter properties is gone on the Current line: code that relied on --experimental-transform-types runs on Node.js 24 and 22 and stops on 26. Second, the official Learn publishing article still mentions that flag as the way to consume enum on the Node.js side (observed on 2026-10-04), and it recommends TypeScript 5.7 where the API page says 5.8; when pages disagree, the API reference for your Node.js release wins.

Where the books are outdated

Two O’Reilly books from 2025 touch on TypeScript: Efficient Node.js by Samer Buna and Node.js Projects by Jonathan Wexler. They are useful on the motivation for static types, and they predate or leave out the native route described on this page.

Table 10. Gaps between the books and the current Node.js documentation
Topic Current state

Running TypeScript

Efficient Node.js has a practical-Node chapter that treats TypeScript as a transpile-and-bundle step: install typescript and @types/node, run tsc to produce .js, then deploy that. Today, node file.ts runs erasable TypeScript directly (Stable) and tsc --noEmit is only the checker. Compiling is still right for published packages.

Configuration

The generated tsconfig.json shown there uses CommonJS settings (target es2016, module commonjs, esModuleInterop). For code that Node.js runs itself, use module: nodenext, erasableSyntaxOnly, verbatimModuleSyntax and rewriteRelativeImportExtensions, and set types explicitly on TypeScript 6 and 7.

Imports

With stripping, type imports need the type modifier and relative imports need the real .ts extension; neither is part of the compile-first workflow.

tsx, runners, --experimental-transform-types

Not covered. tsx is the usual runner today; the transform flag is gone on Node.js 26.

TypeScript in Node.js Projects

The book recommends adopting TypeScript (or JSDoc types) for safety in a short list of practices, without showing how to run it. This page supplies that part.

tsc and type errors

The claim that tsc does not emit JavaScript when the code has type errors is wrong: by default tsc reports the errors, exits with a non-zero status and still writes the .js files (checked with TypeScript 5.9.3, 6.0.3 and 7.0.2). Only "noEmitOnError": true stops the output, and tsc --noEmit is the check-only step.

Pitfalls and good practice

  • Fresh package.json files contain "type": "commonjs"; ESM-syntax .ts files then fail. Set "type": "module", or use .mts.

  • Never rely on a green run as proof that types are right: Node.js does not check them. Run tsc --noEmit locally and in CI.

  • Always write the extension in relative imports (./book.ts) and mark type-only imports with type; turn on verbatimModuleSyntax so the editor tells you.

  • Replace enums, parameter properties and value namespaces; let erasableSyntaxOnly find them.

  • Do not publish .ts entry points; Node.js will not run them from node_modules.

  • Set "types": ["node"] and, for builds, "rootDir"; both defaults changed in TypeScript 6.

  • Keep @types/node on the same major as the oldest Node.js you support.

  • Do not parse the output of module.stripTypeScriptTypes() as stable; it may change between Node.js releases.

  • Observed behaviour, not documented guarantees, in this page: node -e with types and no --input-type; the symlink exception for workspaces; .d.ts files that keep .ts specifiers. Test them again when you upgrade.

References