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);