From 789b878e9b24bc094503d96e52712bae318968ec Mon Sep 17 00:00:00 2001 From: Geoffrey Booth Date: Mon, 15 Jun 2026 16:40:46 -0700 Subject: [PATCH] module: strip types in node_modules with declarations behind a flag By default, Node.js refuses to strip types from `.ts`/`.mts`/`.cts` files under `node_modules`, throwing `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. This protects editor and `tsc` performance: a dependency that ships raw TypeScript without declarations forces consumers to infer types from its source. But the folder-based rule also blocks legitimate cases where trusted first-party TypeScript ends up under `node_modules`, such as monorepo deploys (`pnpm deploy`), packages from a private registry or a Git URL, and globally installed TypeScript CLIs. Add an experimental, opt-in flag `--experimental-strip-types-in-node-modules-with-declarations`. Under it, a TypeScript file under `node_modules` is stripped and executed when a co-located declaration file sits beside it (e.g. `foo.d.ts` next to `foo.ts`), the default layout emitted by `tsc --emitDeclarationOnly`. The declaration's presence signals that the author pre-computed the type boundaries downstream tooling relies on, so editors read declarations instead of inferring from raw source. The check is a single `stat`, and the flag is rejected unless type-stripping is enabled. Refs: https://github.com/nodejs/node/pull/63853 Refs: https://github.com/nodejs/node/pull/63869 Signed-off-by: Geoffrey Booth --- doc/api/cli.md | 15 +++++ doc/api/errors.md | 7 ++- doc/api/typescript.md | 24 +++++++- doc/node.1 | 4 ++ lib/internal/errors.js | 4 +- lib/internal/modules/typescript.js | 55 ++++++++++++++++++- src/node_options.cc | 11 ++++ src/node_options.h | 1 + test/es-module/test-typescript.mjs | 47 ++++++++++++++++ .../ts/node_modules/twin-exports/package.json | 11 ++++ .../ts/node_modules/twin-exports/src/index.ts | 3 + .../twin-exports/types/index.d.ts | 1 + .../ts/node_modules/twin/package.json | 6 ++ .../typescript/ts/node_modules/twin/twin.d.ts | 1 + .../typescript/ts/node_modules/twin/twin.ts | 3 + ...st-import-ts-exports-types-node-modules.ts | 3 + .../ts/test-import-ts-twin-node-modules.ts | 3 + 17 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/typescript/ts/node_modules/twin-exports/package.json create mode 100644 test/fixtures/typescript/ts/node_modules/twin-exports/src/index.ts create mode 100644 test/fixtures/typescript/ts/node_modules/twin-exports/types/index.d.ts create mode 100644 test/fixtures/typescript/ts/node_modules/twin/package.json create mode 100644 test/fixtures/typescript/ts/node_modules/twin/twin.d.ts create mode 100644 test/fixtures/typescript/ts/node_modules/twin/twin.ts create mode 100644 test/fixtures/typescript/ts/test-import-ts-exports-types-node-modules.ts create mode 100644 test/fixtures/typescript/ts/test-import-ts-twin-node-modules.ts diff --git a/doc/api/cli.md b/doc/api/cli.md index 79f02ee5eddd86..f11b8d34da35a1 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1407,6 +1407,20 @@ added: Enable the experimental [`node:stream/iter`][] module. +### `--experimental-strip-types-in-node-modules-with-declarations` + + + +> Stability: 1.0 - Early development + +Allow [TypeScript type-stripping][] for TypeScript files under `node_modules` when a co-located declaration file is +present. By default, Node.js refuses to strip types from files under `node_modules`. With this flag, a file under +`node_modules` is stripped and executed when a co-located declaration file sits beside it (for example, a `.d.ts` file +alongside the `.ts` file), which is the default layout emitted by `tsc --emitDeclarationOnly`. Otherwise the +`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` error is thrown. + ### `--experimental-test-coverage` -Type stripping is not supported for files descendent of a `node_modules` directory. +Type stripping is not supported for TypeScript files descendent of a `node_modules` +directory. With the experimental `--experimental-strip-types-in-node-modules-with-declarations` +flag, such a file is stripped and executed when a co-located declaration file +sits beside it (for example, a `.d.ts` next to the `.ts`); otherwise this error +is thrown. The declaration signals that the package author pre-computed the type +boundaries that downstream tooling relies on. diff --git a/doc/api/typescript.md b/doc/api/typescript.md index 3959fd58b56c84..e8586301d77065 100644 --- a/doc/api/typescript.md +++ b/doc/api/typescript.md @@ -213,9 +213,26 @@ correct line numbers in stack traces; and Node.js does not generate them. ### Type stripping in dependencies -To discourage package authors from publishing packages written in TypeScript, -Node.js refuses to handle TypeScript files inside folders under a `node_modules` -path. +By default, Node.js refuses to strip types from TypeScript files inside folders +under a `node_modules` path, to discourage shipping raw TypeScript without +pre-computed type information. + +With the experimental [`--experimental-strip-types-in-node-modules-with-declarations`][] flag, a +TypeScript file under `node_modules` is stripped and executed when a co-located +declaration file sits beside it (for example, a `mod.d.ts` next to `mod.ts`, a +`mod.d.mts` next to `mod.mts`, or a `mod.d.cts` next to `mod.cts`). This is the +default layout emitted by `tsc --emitDeclarationOnly`. + +The presence of a co-located declaration acts as an opt-in: it signals that the +author ran a declaration emitter and therefore pre-computed the explicit type +boundaries that editors and type-checkers rely on, so they do not need to infer +types from the raw source. Otherwise the +`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` error is thrown. + +This unblocks workflows that copy first-party TypeScript into `node_modules` — +such as `pnpm deploy`, packages installed from a private registry or a Git URL, +and globally installed TypeScript CLIs — without requiring the source to be +transpiled to JavaScript first. ### Paths aliases @@ -226,6 +243,7 @@ with `#`. [CommonJS]: modules.md [ES Modules]: esm.md [Full TypeScript support]: #full-typescript-support +[`--experimental-strip-types-in-node-modules-with-declarations`]: cli.md#--experimental-strip-types-in-node-modules-with-declarations [`--no-strip-types`]: cli.md#--no-strip-types [`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax [`tsconfig` "paths"]: https://www.typescriptlang.org/tsconfig/#paths diff --git a/doc/node.1 b/doc/node.1 index dc42c5287026a7..0e4698019648ba 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -777,6 +777,10 @@ Use this flag to enable ShadowRealm support. .It Fl -experimental-storage-inspection Enable experimental support for storage inspection . +.It Fl -experimental-strip-types-in-node-modules-with-declarations +Allow type-stripping for TypeScript files under node_modules when a +co-located declaration file is present. +. .It Fl -experimental-test-coverage When used in conjunction with the \fBnode:test\fR module, a code coverage report is generated as part of the test runner output. If no tests are run, a coverage diff --git a/lib/internal/errors.js b/lib/internal/errors.js index f09788538ce8f5..7e6fdb39baf8ca 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1908,7 +1908,9 @@ E('ERR_UNSUPPORTED_ESM_URL_SCHEME', (url, supported) => { return msg; }, Error); E('ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING', - 'Stripping types is currently unsupported for files under node_modules, for "%s"', + 'Stripping types is unsupported for files under node_modules unless a ' + + 'co-located declaration file is present (e.g. a ".d.ts" next to the ".ts"), ' + + 'for "%s"', Error); E('ERR_UNSUPPORTED_RESOLVE_REQUEST', 'Failed to resolve module specifier "%s" from "%s": Invalid relative URL or base scheme is not hierarchical.', diff --git a/lib/internal/modules/typescript.js b/lib/internal/modules/typescript.js index 43bc57d977ec27..fa6baf917acce1 100644 --- a/lib/internal/modules/typescript.js +++ b/lib/internal/modules/typescript.js @@ -2,6 +2,9 @@ const { ObjectPrototypeHasOwnProperty, + RegExpPrototypeExec, + StringPrototypeSlice, + StringPrototypeStartsWith, } = primordials; const { validateOneOf, @@ -19,6 +22,9 @@ const { ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, } = require('internal/errors').codes; const assert = require('internal/assert'); +const { getOptionValue } = require('internal/options'); +const { fileURLToPath } = require('internal/url'); +const { internalModuleStat } = internalBinding('fs'); const { getCompileCacheEntry, saveCompileCacheEntry, @@ -141,6 +147,44 @@ function stripTypeScriptTypesForCoverage(code) { } +// Matches a TypeScript source extension, capturing the module-system letter so +// the declaration extension can be reconstructed: `.ts` -> `.d.ts`, +// `.mts` -> `.d.mts`, `.cts` -> `.d.cts`. +const kTypeScriptExtensionRegExp = /\.([mc]?)ts$/; + +/** + * Maps a TypeScript source path to its co-located declaration path (the + * "declaration beside the implementation" rule, the default layout emitted by + * `tsc`). + * @param {string} sourcePath Absolute path to a TypeScript source file. + * @returns {string|undefined} The co-located declaration path, or `undefined` + * if the path does not have a recognized TypeScript extension. + */ +function getColocatedDeclarationFile(sourcePath) { + const match = RegExpPrototypeExec(kTypeScriptExtensionRegExp, sourcePath); + if (match === null) { + return undefined; + } + return `${StringPrototypeSlice(sourcePath, 0, match.index)}.d.${match[1]}ts`; +} + +/** + * Determines whether a TypeScript file under node_modules ships a co-located + * declaration file (e.g. `foo.d.ts` next to `foo.ts`), the default layout + * emitted by `tsc --emitDeclarationOnly`. Its presence signals that the author + * pre-computed the type boundaries downstream tooling relies on, so editors and + * type-checkers read declarations instead of inferring from the raw source. + * @param {string} filename The TypeScript source path or `file:` URL. + * @returns {boolean} `true` if a co-located declaration file exists. + */ +function hasColocatedDeclarationFile(filename) { + const sourcePath = StringPrototypeStartsWith(filename, 'file://') ? + fileURLToPath(filename) : filename; + const declaration = getColocatedDeclarationFile(sourcePath); + // `internalModuleStat` returns 0 for a regular file. + return declaration !== undefined && internalModuleStat(declaration) === 0; +} + /** * Performs type-stripping to TypeScript source code internally. * It is used by internal loaders. @@ -151,8 +195,17 @@ function stripTypeScriptTypesForCoverage(code) { */ function stripTypeScriptModuleTypes(source, filename, sourceURL) { assert(typeof source === 'string'); + // Type-stripping is disallowed inside node_modules. Behind the experimental + // `--experimental-strip-types-in-node-modules-with-declarations` flag it is + // allowed when the package ships a co-located declaration file (e.g. a + // `.d.ts` next to the `.ts`), which signals that the author pre-computed the + // type boundaries downstream tooling relies on. if (isUnderNodeModules(filename)) { - throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); + if (!getOptionValue('--experimental-strip-types-in-node-modules-with-declarations') || + !hasColocatedDeclarationFile(filename)) { + throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); + } + emitExperimentalWarning('Type stripping in node_modules'); } // Get a compile cache entry into the native compile cache store, // keyed by the filename. If the cache can already be loaded on disk, diff --git a/src/node_options.cc b/src/node_options.cc index 7f036cd04163de..88412baa180da5 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -194,6 +194,12 @@ void EnvironmentOptions::CheckOptions(std::vector* errors, errors->push_back("either --check or --eval can be used, not both"); } + if (strip_types_in_node_modules_with_declarations && !strip_types) { + errors->push_back( + "--experimental-strip-types-in-node-modules-with-declarations " + "requires type-stripping (--strip-types) to be enabled"); + } + if (!unhandled_rejections.empty() && unhandled_rejections != "warn-with-error-code" && unhandled_rejections != "throw" && @@ -1204,6 +1210,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { kAllowedInEnvvar, HAVE_AMARO); AddAlias("--experimental-strip-types", "--strip-types"); + AddOption("--experimental-strip-types-in-node-modules-with-declarations", + "Allow type-stripping for TypeScript files under node_modules when " + "the package provides a resolvable declaration for the module.", + &EnvironmentOptions::strip_types_in_node_modules_with_declarations, + kAllowedInEnvvar); AddOption("--interactive", "always enter the REPL even if stdin does not appear " "to be a terminal", diff --git a/src/node_options.h b/src/node_options.h index f7a8a999d85a1b..8703b89ae41d41 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -273,6 +273,7 @@ class EnvironmentOptions : public Options { std::vector preload_esm_modules; bool strip_types = HAVE_AMARO; + bool strip_types_in_node_modules_with_declarations = false; std::vector user_argv; diff --git a/test/es-module/test-typescript.mjs b/test/es-module/test-typescript.mjs index fd58d1d990a527..3c4afd9b0cf2fb 100644 --- a/test/es-module/test-typescript.mjs +++ b/test/es-module/test-typescript.mjs @@ -174,6 +174,53 @@ test('execute CommonJS TypeScript file from node_modules with require-module', a assert.strictEqual(result.code, 1); }); +test('strip a node_modules .ts with a co-located .d.ts when the flag is set', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-warnings', + '--experimental-strip-types-in-node-modules-with-declarations', + fixtures.path('typescript/ts/test-import-ts-twin-node-modules.ts'), + ]); + + assert.strictEqual(result.stderr, ''); + assert.match(result.stdout, /Hello, TypeScript!/); + assert.strictEqual(result.code, 0); +}); + +// A declaration that is not co-located with the source (only reachable via the +// `exports` "types" condition, in a separate directory) is not stripped: the +// flag only recognizes a declaration file beside the source. +test('node_modules .ts with a non-co-located declaration is not stripped', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types-in-node-modules-with-declarations', + fixtures.path('typescript/ts/test-import-ts-exports-types-node-modules.ts'), + ]); + + assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/); + assert.strictEqual(result.stdout, ''); + assert.strictEqual(result.code, 1); +}); + +test('node_modules .ts with declarations is still blocked without the flag', async () => { + const result = await spawnPromisified(process.execPath, [ + fixtures.path('typescript/ts/test-import-ts-twin-node-modules.ts'), + ]); + + assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/); + assert.strictEqual(result.stdout, ''); + assert.strictEqual(result.code, 1); +}); + +test('the node_modules declarations flag requires type-stripping enabled', async () => { + const result = await spawnPromisified(process.execPath, [ + '--no-strip-types', + '--experimental-strip-types-in-node-modules-with-declarations', + fixtures.path('typescript/ts/test-import-ts-twin-node-modules.ts'), + ]); + + assert.match(result.stderr, /requires type-stripping \(--strip-types\) to be enabled/); + assert.strictEqual(result.code, 9); +}); + test('execute a TypeScript file with CommonJS syntax requiring .cts', async () => { const result = await spawnPromisified(process.execPath, [ '--no-warnings', diff --git a/test/fixtures/typescript/ts/node_modules/twin-exports/package.json b/test/fixtures/typescript/ts/node_modules/twin-exports/package.json new file mode 100644 index 00000000000000..35efa296fc5575 --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/twin-exports/package.json @@ -0,0 +1,11 @@ +{ + "name": "twin-exports", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "types": "./types/index.d.ts", + "import": "./src/index.ts" + } + } +} diff --git a/test/fixtures/typescript/ts/node_modules/twin-exports/src/index.ts b/test/fixtures/typescript/ts/node_modules/twin-exports/src/index.ts new file mode 100644 index 00000000000000..a0c7cfa472ca10 --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/twin-exports/src/index.ts @@ -0,0 +1,3 @@ +const message: string = 'Hello, TypeScript!'; + +export { message }; diff --git a/test/fixtures/typescript/ts/node_modules/twin-exports/types/index.d.ts b/test/fixtures/typescript/ts/node_modules/twin-exports/types/index.d.ts new file mode 100644 index 00000000000000..ed0020285c511e --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/twin-exports/types/index.d.ts @@ -0,0 +1 @@ +export declare const message: string; diff --git a/test/fixtures/typescript/ts/node_modules/twin/package.json b/test/fixtures/typescript/ts/node_modules/twin/package.json new file mode 100644 index 00000000000000..5c6d0e48c5930b --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/twin/package.json @@ -0,0 +1,6 @@ +{ + "name": "twin", + "version": "1.0.0", + "type": "module", + "exports": "./twin.ts" +} diff --git a/test/fixtures/typescript/ts/node_modules/twin/twin.d.ts b/test/fixtures/typescript/ts/node_modules/twin/twin.d.ts new file mode 100644 index 00000000000000..b3aadf354ddd17 --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/twin/twin.d.ts @@ -0,0 +1 @@ +export declare const twin: string; diff --git a/test/fixtures/typescript/ts/node_modules/twin/twin.ts b/test/fixtures/typescript/ts/node_modules/twin/twin.ts new file mode 100644 index 00000000000000..a99f01ac27abf4 --- /dev/null +++ b/test/fixtures/typescript/ts/node_modules/twin/twin.ts @@ -0,0 +1,3 @@ +const twin: string = 'Hello, TypeScript!'; + +export { twin }; diff --git a/test/fixtures/typescript/ts/test-import-ts-exports-types-node-modules.ts b/test/fixtures/typescript/ts/test-import-ts-exports-types-node-modules.ts new file mode 100644 index 00000000000000..c07a3d01b65c17 --- /dev/null +++ b/test/fixtures/typescript/ts/test-import-ts-exports-types-node-modules.ts @@ -0,0 +1,3 @@ +import { message } from 'twin-exports'; + +console.log(message); diff --git a/test/fixtures/typescript/ts/test-import-ts-twin-node-modules.ts b/test/fixtures/typescript/ts/test-import-ts-twin-node-modules.ts new file mode 100644 index 00000000000000..e7da18a9471d7b --- /dev/null +++ b/test/fixtures/typescript/ts/test-import-ts-twin-node-modules.ts @@ -0,0 +1,3 @@ +import { twin } from 'twin'; + +console.log(twin);