- Book: TypeScript in Production
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You stop compiling. Your package's exports points straight at ./src/index.ts, every script in the monorepo runs with plain node, and the tests are green. Then someone installs the package from the registry, runs their app, and Node stops with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
No flag turns it off. The Node.js TypeScript docs give the reason in one sentence: "To discourage package authors from publishing packages written in TypeScript, Node.js refuses to handle TypeScript files inside folders under a node_modules path."
The refusal makes sense once you see how little Node does to a .ts file.
How Node runs a .ts file without compiling it
Type stripping landed in v22.6.0, became the default in v23.6.0 and v22.18.0, and was marked stable in v25.2.0 and v24.12.0. Current releases of every LTS line on the Node releases page (22, 24 and 26, with v26.13.0 the newest LTS release listed there at the time of writing) have it on by default. You opt out with --no-strip-types.
The mechanism is deliberately dumb. Node doesn't compile anything. It finds the TypeScript syntax and overwrites it with spaces. This is the example from the node:module docs:
import { stripTypeScriptTypes } from 'node:module';
const code = 'const a: number = 1;';
const strippedCode = stripTypeScriptTypes(code);
console.log(strippedCode);
// Prints: const a = 1;
The = 1 is still in the column where you typed it. An interface that spans twelve lines becomes twelve blank lines. Because nothing moves, a stack trace points at the right line of your .ts file, and the docs note that "source maps are unnecessary for correct line numbers in stack traces; and Node.js does not generate them." The parser doing this is Amaro, a wrapper around SWC's TypeScript parser compiled to WebAssembly.
Being dumb has consequences, and they're all in the docs. There is no type checking. Node ignores tsconfig.json, so paths aliases don't work. Imports need their real extension (./user.ts, not ./user). Type-only imports must say import type, or Node treats them as values and fails at runtime. .tsx is unsupported.

Type stripping removes the annotations and leaves the gaps, so every line and column stays where it was.
The syntax whitespace can't erase
If stripping is "replace with spaces", anything that needs Node to write new JavaScript is out. The Node docs list the main cases: enums, namespaces with runtime code, parameter properties and import aliases. Hitting one throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
The reason is visible in what tsc emits for them. An enum is a real object at runtime, so the compiler has to generate the code that builds it. A parameter property is shorthand for an assignment the compiler writes into the constructor. Neither exists in your source as JavaScript, so blanking characters can't produce it.
// Rejected by type stripping
class User {
constructor(public name: string) {}
}
enum Role { Admin, Viewer }
The erasable versions say the same thing in syntax that's already JavaScript once the types are gone:
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
const Role = { Admin: 0, Viewer: 1 } as const;
type Role = (typeof Role)[keyof typeof Role];
You don't want to find these one runtime crash at a time. TypeScript 5.8 added erasableSyntaxOnly for exactly this: with it on, tsc errors on enums, namespaces and modules with runtime code, parameter properties, import = and export =. The tsconfig reference adds <number>value-style assertions to the list. The Node docs recommend TypeScript 5.8 or newer with erasableSyntaxOnly, verbatimModuleSyntax and rewriteRelativeImportExtensions turned on.
There used to be an escape hatch. --experimental-transform-types arrived in v22.7.0 and was removed in v26.0.0, along with the transform mode of stripTypeScriptTypes. On Node 26 the built-in path is strip-only. For full TypeScript, the docs point you to a third-party runner such as tsx.
Reproduce the refusal (your monorepo hides it)
Here's a two-package npm workspace where the library ships raw TypeScript:
greet-demo/
├── package.json "workspaces": ["packages/*"]
└── packages/
├── greet-ts/
│ ├── package.json
│ └── src/index.ts
└── app/
├── package.json
└── main.ts
The library's manifest points exports at source:
{
"name": "greet-ts",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts"
}
// packages/greet-ts/src/index.ts
export function greet(name: string): string {
return `hello, ${name}`;
}
// packages/app/main.ts (app has "type": "module")
import { greet } from "greet-ts";
console.log(greet("ada"));
Run npm install at the root, then node packages/app/main.ts, and it should run. That's because npm symlinks each workspace into node_modules. By default Node dereferences symlinks and uses the module's "real path" as its identity. As Jake Bailey points out in nodejs/typescript#14, the "is this in node_modules" check looks at that real path. So Node sees packages/greet-ts/src/index.ts, with no node_modules anywhere in it, and strips it happily.
Your consumers don't get a symlink. They get a copy. Pack the library the way the registry would and install the tarball somewhere else:
cd ~/code/greet-demo/packages/greet-ts
npm pack
mkdir ~/code/consumer && cd ~/code/consumer
npm init -y
npm install ~/code/greet-demo/packages/greet-ts/greet-ts-1.0.0.tgz
Copy main.ts over as main.mts (.mts always runs as ESM, whatever npm init wrote into type) and run node main.mts. The library's index.ts now lives at node_modules/greet-ts/src/index.ts. That is the case the errors reference describes for ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING: "Type stripping is not supported for files descendant of a node_modules directory." I haven't run this exact repro myself; the error code and its trigger come straight from those docs. npm's install-links setting, which packs file: dependencies (not workspaces) instead of symlinking them, is another way to get the same copy-not-link setup in CI.

A symlinked workspace package resolves outside node_modules and runs. The same files installed from a tarball sit inside node_modules and are refused.
Why Node says no
The request to lift the restriction is nodejs/typescript#14, and it was closed as not planned. The issue's author makes a fair case: Bun, Deno and JSR already run or host TypeScript source, so Node holding out alone won't stop the trend. Replies in the thread explain the other side. Matteo Collina wrote that "TypeScript is designed so that only .d.ts files are shared across projects." Jake Bailey raised the problem that a published .ts file gets type-checked under the consumer's tsconfig, not the author's. Jordan Harband added that, as he understood it, the TypeScript team opposes the idea too, because it would mean they could never make breaking changes to TypeScript's syntax again.
TypeScript's own compiler options guide shows the type-checking cost. A separate outDir is "necessary for libraries that publish their input files. Otherwise, extension substitution will cause the library's consumers to load the library's .ts files instead of .d.ts files, causing type errors and performance problems." Raw .ts in node_modules hands the consumer's compiler your source and your strictness assumptions.
If you control both ends, there is a workaround. One commenter in the thread shares a loader built on module.registerHooks() and stripTypeScriptTypes(), both "release candidate" stability. That loader calls stripTypeScriptTypes() in transform mode, which Node 26 removed, so on current Node the same idea would have to use the default strip mode and handle erasable syntax only. That fits an internal app. It isn't something to ask the users of a public package to install.
What your package still ships
Your own scripts and tests can run the .ts files directly. The thing you publish is still compiled JavaScript plus declarations. One tsconfig covers both, combining the Node recommendations with the library settings from the TypeScript guide:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true,
"declaration": true,
"rootDir": "src",
"outDir": "dist"
}
}
rewriteRelativeImportExtensions (TypeScript 5.7) is what lets one source tree serve both: you write import { x } from "./x.ts", Node runs it as-is, and tsc rewrites it to ./x.js in dist. Then the manifest points at the build output:
{
"name": "greet-ts",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": { "build": "tsc -p tsconfig.json" }
}
Key order matters in conditional exports: "earlier entries have higher priority," and the types condition "should always be included first." Node doesn't type-check, so keep a tsc --noEmit step in CI.
JSR: the registry that compiles for you
JSR is the one registry where publishing TypeScript source is the expected path. It "supports and encourages publishing TypeScript source code rather than pairs of .js + .d.ts files." The catch is what reaches Node. Through JSR's npm compatibility layer, "this source code is transpiled to JavaScript," and .d.ts files "are generated for all TypeScript files." A Node user who runs npx jsr add, or installs with pnpm 10.9+ or Yarn 4.9+, gets JavaScript in node_modules.
That's also why JSR enforces its "slow types" rule on exported functions, classes and variables: explicit types are what let it generate declarations for the npm layer.
If this was useful
Getting a package from "it runs in my repo" to "it runs in yours" is most of what TypeScript in Production covers: tsconfig choices, build tools, monorepos, exports maps, dual ESM/CJS and publishing to JSR. If your team has started running .ts directly and is wondering what still needs a build, that book is the one to grab.
It's the fifth book in The TypeScript Library:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
Books 1 and 2 are the core path. If you're coming from the JVM or PHP, swap in book 3 or 4 for those. Book 5 is for anyone shipping TypeScript at work, which, if you read this far, is probably you.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)