Skip to content

Commit 1826070

Browse files
committed
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: #63853 Refs: #63869 Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
1 parent 463e56f commit 1826070

16 files changed

Lines changed: 188 additions & 6 deletions

File tree

doc/api/cli.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,20 @@ added:
14071407
14081408
Enable the experimental [`node:stream/iter`][] module.
14091409

1410+
### `--experimental-strip-types-in-node-modules-with-declarations`
1411+
1412+
<!-- YAML
1413+
added: REPLACEME
1414+
-->
1415+
1416+
> Stability: 1.0 - Early development
1417+
1418+
Allow [TypeScript type-stripping][] for TypeScript files under `node_modules` when a co-located declaration file is
1419+
present. By default, Node.js refuses to strip types from files under `node_modules`. With this flag, a file under
1420+
`node_modules` is stripped and executed when a co-located declaration file sits beside it (for example, a `.d.ts` file
1421+
alongside the `.ts` file), which is the default layout emitted by `tsc --emitDeclarationOnly`. Otherwise the
1422+
`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` error is thrown.
1423+
14101424
### `--experimental-test-coverage`
14111425

14121426
<!-- YAML

doc/api/errors.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3393,7 +3393,12 @@ import 'package-name'; // supported
33933393
added: v22.6.0
33943394
-->
33953395

3396-
Type stripping is not supported for files descendent of a `node_modules` directory.
3396+
Type stripping is not supported for TypeScript files descendent of a `node_modules`
3397+
directory. With the experimental `--experimental-strip-types-in-node-modules-with-declarations`
3398+
flag, such a file is stripped and executed when a co-located declaration file
3399+
sits beside it (for example, a `.d.ts` next to the `.ts`); otherwise this error
3400+
is thrown. The declaration signals that the package author pre-computed the type
3401+
boundaries that downstream tooling relies on.
33973402

33983403
<a id="ERR_UNSUPPORTED_RESOLVE_REQUEST"></a>
33993404

doc/api/typescript.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,26 @@ correct line numbers in stack traces; and Node.js does not generate them.
213213

214214
### Type stripping in dependencies
215215

216-
To discourage package authors from publishing packages written in TypeScript,
217-
Node.js refuses to handle TypeScript files inside folders under a `node_modules`
218-
path.
216+
By default, Node.js refuses to strip types from TypeScript files inside folders
217+
under a `node_modules` path, to discourage shipping raw TypeScript without
218+
pre-computed type information.
219+
220+
With the experimental [`--experimental-strip-types-in-node-modules-with-declarations`][] flag, a
221+
TypeScript file under `node_modules` is stripped and executed when a co-located
222+
declaration file sits beside it (for example, a `mod.d.ts` next to `mod.ts`, a
223+
`mod.d.mts` next to `mod.mts`, or a `mod.d.cts` next to `mod.cts`). This is the
224+
default layout emitted by `tsc --emitDeclarationOnly`.
225+
226+
The presence of a co-located declaration acts as an opt-in: it signals that the
227+
author ran a declaration emitter and therefore pre-computed the explicit type
228+
boundaries that editors and type-checkers rely on, so they do not need to infer
229+
types from the raw source. Otherwise the
230+
`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` error is thrown.
231+
232+
This unblocks workflows that copy first-party TypeScript into `node_modules`
233+
such as `pnpm deploy`, packages installed from a private registry or a Git URL,
234+
and globally installed TypeScript CLIs — without requiring the source to be
235+
transpiled to JavaScript first.
219236

220237
### Paths aliases
221238

@@ -226,6 +243,7 @@ with `#`.
226243
[CommonJS]: modules.md
227244
[ES Modules]: esm.md
228245
[Full TypeScript support]: #full-typescript-support
246+
[`--experimental-strip-types-in-node-modules-with-declarations`]: cli.md#--experimental-strip-types-in-node-modules-with-declarations
229247
[`--no-strip-types`]: cli.md#--no-strip-types
230248
[`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
231249
[`tsconfig` "paths"]: https://www.typescriptlang.org/tsconfig/#paths

lib/internal/errors.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1908,7 +1908,9 @@ E('ERR_UNSUPPORTED_ESM_URL_SCHEME', (url, supported) => {
19081908
return msg;
19091909
}, Error);
19101910
E('ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING',
1911-
'Stripping types is currently unsupported for files under node_modules, for "%s"',
1911+
'Stripping types is unsupported for files under node_modules unless a ' +
1912+
'co-located declaration file is present (e.g. a ".d.ts" next to the ".ts"), ' +
1913+
'for "%s"',
19121914
Error);
19131915
E('ERR_UNSUPPORTED_RESOLVE_REQUEST',
19141916
'Failed to resolve module specifier "%s" from "%s": Invalid relative URL or base scheme is not hierarchical.',

lib/internal/modules/typescript.js

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
const {
44
ObjectPrototypeHasOwnProperty,
5+
RegExpPrototypeExec,
6+
StringPrototypeSlice,
7+
StringPrototypeStartsWith,
58
} = primordials;
69
const {
710
validateOneOf,
@@ -19,6 +22,9 @@ const {
1922
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,
2023
} = require('internal/errors').codes;
2124
const assert = require('internal/assert');
25+
const { getOptionValue } = require('internal/options');
26+
const { fileURLToPath } = require('internal/url');
27+
const { internalModuleStat } = internalBinding('fs');
2228
const {
2329
getCompileCacheEntry,
2430
saveCompileCacheEntry,
@@ -141,6 +147,44 @@ function stripTypeScriptTypesForCoverage(code) {
141147
}
142148

143149

150+
// Matches a TypeScript source extension, capturing the module-system letter so
151+
// the declaration extension can be reconstructed: `.ts` -> `.d.ts`,
152+
// `.mts` -> `.d.mts`, `.cts` -> `.d.cts`.
153+
const kTypeScriptExtensionRegExp = /\.([mc]?)ts$/;
154+
155+
/**
156+
* Maps a TypeScript source path to its co-located declaration path (the
157+
* "declaration beside the implementation" rule, the default layout emitted by
158+
* `tsc`).
159+
* @param {string} sourcePath Absolute path to a TypeScript source file.
160+
* @returns {string|undefined} The co-located declaration path, or `undefined`
161+
* if the path does not have a recognized TypeScript extension.
162+
*/
163+
function getColocatedDeclarationFile(sourcePath) {
164+
const match = RegExpPrototypeExec(kTypeScriptExtensionRegExp, sourcePath);
165+
if (match === null) {
166+
return undefined;
167+
}
168+
return `${StringPrototypeSlice(sourcePath, 0, match.index)}.d.${match[1]}ts`;
169+
}
170+
171+
/**
172+
* Determines whether a TypeScript file under node_modules ships a co-located
173+
* declaration file (e.g. `foo.d.ts` next to `foo.ts`), the default layout
174+
* emitted by `tsc --emitDeclarationOnly`. Its presence signals that the author
175+
* pre-computed the type boundaries downstream tooling relies on, so editors and
176+
* type-checkers read declarations instead of inferring from the raw source.
177+
* @param {string} filename The TypeScript source path or `file:` URL.
178+
* @returns {boolean} `true` if a co-located declaration file exists.
179+
*/
180+
function hasColocatedDeclarationFile(filename) {
181+
const sourcePath = StringPrototypeStartsWith(filename, 'file://') ?
182+
fileURLToPath(filename) : filename;
183+
const declaration = getColocatedDeclarationFile(sourcePath);
184+
// `internalModuleStat` returns 0 for a regular file.
185+
return declaration !== undefined && internalModuleStat(declaration) === 0;
186+
}
187+
144188
/**
145189
* Performs type-stripping to TypeScript source code internally.
146190
* It is used by internal loaders.
@@ -151,8 +195,17 @@ function stripTypeScriptTypesForCoverage(code) {
151195
*/
152196
function stripTypeScriptModuleTypes(source, filename, sourceURL) {
153197
assert(typeof source === 'string');
198+
// Type-stripping is disallowed inside node_modules. Behind the experimental
199+
// `--experimental-strip-types-in-node-modules-with-declarations` flag it is
200+
// allowed when the package ships a co-located declaration file (e.g. a
201+
// `.d.ts` next to the `.ts`), which signals that the author pre-computed the
202+
// type boundaries downstream tooling relies on.
154203
if (isUnderNodeModules(filename)) {
155-
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
204+
if (!getOptionValue('--experimental-strip-types-in-node-modules-with-declarations') ||
205+
!hasColocatedDeclarationFile(filename)) {
206+
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
207+
}
208+
emitExperimentalWarning('Type stripping in node_modules');
156209
}
157210
// Get a compile cache entry into the native compile cache store,
158211
// keyed by the filename. If the cache can already be loaded on disk,

src/node_options.cc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
194194
errors->push_back("either --check or --eval can be used, not both");
195195
}
196196

197+
if (strip_types_in_node_modules_with_declarations && !strip_types) {
198+
errors->push_back(
199+
"--experimental-strip-types-in-node-modules-with-declarations "
200+
"requires type-stripping (--strip-types) to be enabled");
201+
}
202+
197203
if (!unhandled_rejections.empty() &&
198204
unhandled_rejections != "warn-with-error-code" &&
199205
unhandled_rejections != "throw" &&
@@ -1204,6 +1210,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
12041210
kAllowedInEnvvar,
12051211
HAVE_AMARO);
12061212
AddAlias("--experimental-strip-types", "--strip-types");
1213+
AddOption("--experimental-strip-types-in-node-modules-with-declarations",
1214+
"Allow type-stripping for TypeScript files under node_modules when "
1215+
"the package provides a resolvable declaration for the module.",
1216+
&EnvironmentOptions::strip_types_in_node_modules_with_declarations,
1217+
kAllowedInEnvvar);
12071218
AddOption("--interactive",
12081219
"always enter the REPL even if stdin does not appear "
12091220
"to be a terminal",

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ class EnvironmentOptions : public Options {
273273
std::vector<std::string> preload_esm_modules;
274274

275275
bool strip_types = HAVE_AMARO;
276+
bool strip_types_in_node_modules_with_declarations = false;
276277

277278
std::vector<std::string> user_argv;
278279

test/es-module/test-typescript.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,53 @@ test('execute CommonJS TypeScript file from node_modules with require-module', a
174174
assert.strictEqual(result.code, 1);
175175
});
176176

177+
test('strip a node_modules .ts with a co-located .d.ts when the flag is set', async () => {
178+
const result = await spawnPromisified(process.execPath, [
179+
'--no-warnings',
180+
'--experimental-strip-types-in-node-modules-with-declarations',
181+
fixtures.path('typescript/ts/test-import-ts-twin-node-modules.ts'),
182+
]);
183+
184+
assert.strictEqual(result.stderr, '');
185+
assert.match(result.stdout, /Hello, TypeScript!/);
186+
assert.strictEqual(result.code, 0);
187+
});
188+
189+
// A declaration that is not co-located with the source (only reachable via the
190+
// `exports` "types" condition, in a separate directory) is not stripped: the
191+
// flag only recognizes a declaration file beside the source.
192+
test('node_modules .ts with a non-co-located declaration is not stripped', async () => {
193+
const result = await spawnPromisified(process.execPath, [
194+
'--experimental-strip-types-in-node-modules-with-declarations',
195+
fixtures.path('typescript/ts/test-import-ts-exports-types-node-modules.ts'),
196+
]);
197+
198+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
199+
assert.strictEqual(result.stdout, '');
200+
assert.strictEqual(result.code, 1);
201+
});
202+
203+
test('node_modules .ts with declarations is still blocked without the flag', async () => {
204+
const result = await spawnPromisified(process.execPath, [
205+
fixtures.path('typescript/ts/test-import-ts-twin-node-modules.ts'),
206+
]);
207+
208+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
209+
assert.strictEqual(result.stdout, '');
210+
assert.strictEqual(result.code, 1);
211+
});
212+
213+
test('the node_modules declarations flag requires type-stripping enabled', async () => {
214+
const result = await spawnPromisified(process.execPath, [
215+
'--no-strip-types',
216+
'--experimental-strip-types-in-node-modules-with-declarations',
217+
fixtures.path('typescript/ts/test-import-ts-twin-node-modules.ts'),
218+
]);
219+
220+
assert.match(result.stderr, /requires type-stripping \(--strip-types\) to be enabled/);
221+
assert.strictEqual(result.code, 9);
222+
});
223+
177224
test('execute a TypeScript file with CommonJS syntax requiring .cts', async () => {
178225
const result = await spawnPromisified(process.execPath, [
179226
'--no-warnings',

test/fixtures/typescript/ts/node_modules/twin-exports/package.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/typescript/ts/node_modules/twin-exports/src/index.ts

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)