Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,20 @@ added:

Enable the experimental [`node:stream/iter`][] module.

### `--experimental-strip-types-in-node-modules-with-declarations`

<!-- YAML
added: REPLACEME
-->

> 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`

<!-- YAML
Expand Down
7 changes: 6 additions & 1 deletion doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -3393,7 +3393,12 @@ import 'package-name'; // supported
added: v22.6.0
-->

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.

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

Expand Down
24 changes: 21 additions & 3 deletions doc/api/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
55 changes: 54 additions & 1 deletion lib/internal/modules/typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

const {
ObjectPrototypeHasOwnProperty,
RegExpPrototypeExec,
StringPrototypeSlice,
StringPrototypeStartsWith,
} = primordials;
const {
validateOneOf,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* 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" &&
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ class EnvironmentOptions : public Options {
std::vector<std::string> preload_esm_modules;

bool strip_types = HAVE_AMARO;
bool strip_types_in_node_modules_with_declarations = false;

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

Expand Down
47 changes: 47 additions & 0 deletions test/es-module/test-typescript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 11 additions & 0 deletions test/fixtures/typescript/ts/node_modules/twin-exports/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions test/fixtures/typescript/ts/node_modules/twin/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/typescript/ts/node_modules/twin/twin.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/fixtures/typescript/ts/node_modules/twin/twin.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { message } from 'twin-exports';

console.log(message);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { twin } from 'twin';

console.log(twin);
Loading