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
| API or flag | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
Type stripping of |
2 Stable |
Added in v22.6.0 behind |
|
2 Stable |
Renamed from |
|
Removed |
Added in v22.7.0, removed in v26.0.0 ( |
|
Documented under |
Added in v23.6.0 and v22.18.0, for |
|
1.2 Release candidate |
Added in v23.0.0 and v22.10.0. Value |
|
1.2 Release candidate |
Added in v23.2.0 and v22.13.0. The |
|
Error code |
Added in v23.7.0 and v22.14.0. |
|
Error code |
Added in v22.6.0. |
|
Stable |
Work the same as in |
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.
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.
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();
}
Source: Node.js API: Type stripping
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);
Source: Node.js API: Importing types without type keyword, Node.js Learn: Running TypeScript Natively
node src/main.ts
[ '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.
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.
Source: Node.js API: new Worker()
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).
| Syntax | Result with type stripping | Erasable replacement |
|---|---|---|
Type annotations, |
Erased. Runs. |
Nothing to do. |
|
|
A |
Parameter properties ( |
|
An explicit field and an assignment in the constructor. |
|
|
An ES module, or a plain object. A |
Import aliases ( |
Not supported (listed by the official page) |
|
Decorators |
Parse error ( |
Plain function calls, or a runner or compiler that supports them. |
|
|
Use a runner or compile with |
Options that live in |
Ignored: Node.js never reads |
Subpath imports ( |
The three most common stumbles, as the shell shows them. The enum case is first.
enum Status { Draft, Published }
console.log(Status.Published);
Source: Node.js API: TypeScript features
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):
// 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);
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.
import { Book } from '../src/book.ts';
const b: Book = { isbn: 'x', title: 'y' };
console.log(b.title);
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.
| Extension | Module system | Observed on Node.js 26, 24 and 22.23.3 |
|---|---|---|
|
Always an ES module, like |
|
|
Always CommonJS, like |
|
|
Decided by the nearest |
|
|
Not supported |
|
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.
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
| Option | Effect |
|---|---|
|
Turns type stripping off. A |
|
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. |
|
Transformed enums, namespaces and parameter properties on Node.js 22 and 24, printing an |
|
Select the module system for |
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:
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'));
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.
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);
}
"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.
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.
{
"name": "@bookshelf/shared",
"version": "1.0.0",
"type": "module",
"exports": "./index.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.
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.
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"types": ["node"]
},
"include": ["src"]
}
| Option | Purpose |
|---|---|
|
Makes the compiler follow Node.js’s rules: the |
|
Allows |
|
Makes |
|
Requires |
|
Node.js runs the newest syntax the engine supports, so nothing needs down-levelling. Choose a lower target only for a build you publish. |
|
Optional: for scripts that are only executed, not distributed. Leave it out, or override it, for a package you compile. |
|
|
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:
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:
{
"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.
Source: Node.js API: Type stripping (recommended tsconfig.json), Node.js Learn: Type checking, TypeScript: TSConfig Reference
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.
| Behaviour | 5.9.3 | 6.0.3 | 7.0.2 |
|---|---|---|---|
|
Yes |
No: |
No: the same error |
|
Layout inferred ( |
Documented: |
|
|
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.
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.
| Route | What it does | Type checks? | Choose it when |
|---|---|---|---|
Native stripping ( |
Blanks out types, runs the file |
No (use |
Scripts, services, tests and tools that stay within erasable syntax. No extra dependency, no build. |
Compile with |
Converts every supported feature to JavaScript, writes output files |
|
You publish a package, need old targets, decorators or JSX, or deploy a bundle. |
A runner such as |
Loads TypeScript on the fly (esbuild based); reads |
No |
You need non-erasable syntax or |
Learn also lists |
Runs TypeScript with type checking unless |
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):
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);
npm install --save-dev tsx
npx tsx full/rich.ts
node --import=tsx full/rich.ts
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.
Source: Node.js Learn: Running TypeScript with a runner, Node.js API: Full TypeScript support, tsx on GitHub
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.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"target": "es2022",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true
},
"exclude": ["src/**/*.test.ts"]
}
npx tsc -p tsconfig.build.json
cat dist/index.js
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.
NODE_OPTIONS="--experimental-import-meta-resolve" npx codemod @nodejs/correct-ts-specifiers
- 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 #.
{
"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.
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)
export { addBook, titles } from './book.ts';
export type { Book, Shelf } from './book.ts';
{
"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.npmignorefor the same job; an allow-list fails safe). -
exportswith atypescondition first, thendefault, points consumers and the compiler at the output. Puttypesbefore the runtime condition, because conditions are matched in order. -
prepackruns beforenpm packandnpm publish, so the build cannot be forgotten. The Learn article uses the same script name;node --rundoes not runpreandpostscripts, so do not use it for the packing step. -
enginesdocuments 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 thees2022target runs on Node.js 22 and later. -
@types/nodeis 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.
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:
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
| Situation | Use |
|---|---|
A script, a CLI, an internal service or a test suite for Node.js 22.18 or later |
Native stripping, the recommended |
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 |
A library or package for other people |
Compile with |
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 |
Node.js older than 22.18 |
Upgrade (see Release Lines & LTS), or use |
What changed recently
| Release | Change |
|---|---|
v22.6.0 (2024) |
|
v22.7.0 |
|
v22.13.0, v23.2.0 |
|
v22.14.0, v23.7.0 |
|
v22.18.0, v23.6.0 |
Type stripping enabled by default, so no flag is needed; |
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 |
v26.0.0 |
|
TypeScript 5.7 / 5.8 |
|
TypeScript 6.0, 7.0 |
|
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.
| Topic | Current state |
|---|---|
Running TypeScript |
Efficient Node.js has a practical-Node chapter that treats TypeScript as a transpile-and-bundle step: install |
Configuration |
The generated |
Imports |
With stripping, type imports need the |
|
Not covered. |
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. |
|
The claim that |
Pitfalls and good practice
-
Fresh
package.jsonfiles contain"type": "commonjs"; ESM-syntax.tsfiles 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 --noEmitlocally and in CI. -
Always write the extension in relative imports (
./book.ts) and mark type-only imports withtype; turn onverbatimModuleSyntaxso the editor tells you. -
Replace enums, parameter properties and value namespaces; let
erasableSyntaxOnlyfind them. -
Do not publish
.tsentry points; Node.js will not run them fromnode_modules. -
Set
"types": ["node"]and, for builds,"rootDir"; both defaults changed in TypeScript 6. -
Keep
@types/nodeon 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 -ewith types and no--input-type; the symlink exception for workspaces;.d.tsfiles that keep.tsspecifiers. Test them again when you upgrade.
References
-
Node.js Learn: Introduction to TypeScript, https://nodejs.org/learn/typescript/introduction
-
Node.js Learn: Running TypeScript Natively, https://nodejs.org/learn/typescript/run-natively
-
Node.js Learn: Running TypeScript code using transpilation, https://nodejs.org/learn/typescript/transpile
-
Node.js Learn: Running TypeScript with a runner, https://nodejs.org/learn/typescript/run
-
Node.js Learn: Publishing a TypeScript package, https://nodejs.org/learn/typescript/publishing-a-ts-package
-
Node.js Learn: Correct TypeScript Specifiers (userland migrations), https://nodejs.org/learn/userland-migrations/correct-ts-specifiers
-
Node.js API: Modules: TypeScript, https://nodejs.org/docs/latest/api/typescript.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Modules: node:module API, module.stripTypeScriptTypes(), https://nodejs.org/docs/latest/api/module.html#modulestriptypescripttypescode-options
-
Node.js API: Process, process.features.typescript, https://nodejs.org/docs/latest/api/process.html#processfeaturestypescript
-
Node.js API: Errors, https://nodejs.org/docs/latest/api/errors.html
-
Node.js API: Modules: Packages, subpath imports, https://nodejs.org/docs/latest/api/packages.html#subpath-imports
-
Node.js API: Worker threads, https://nodejs.org/docs/latest/api/worker_threads.html
-
Amaro (TypeScript type stripping for Node.js), https://github.com/nodejs/amaro
-
TypeScript: TSConfig Reference, https://www.typescriptlang.org/tsconfig/
-
TypeScript 5.7 release notes, https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html
-
TypeScript 5.8 release notes, https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html
-
TypeScript blog: Announcing TypeScript 6.0, https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/
-
TypeScript blog: Announcing TypeScript 7.0, https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/
-
DefinitelyTyped: types/node, https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node
-
npm Docs: package.json, https://docs.npmjs.com/cli/v11/configuring-npm/package-json/
-
npm Docs: scripts, https://docs.npmjs.com/cli/v11/using-npm/scripts/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/