Skip to content

module: allow .ts in node_modules when .d.ts is present#63936

Closed
GeoffreyBooth wants to merge 1 commit into
nodejs:mainfrom
GeoffreyBooth:strip-types-twin-dts
Closed

module: allow .ts in node_modules when .d.ts is present#63936
GeoffreyBooth wants to merge 1 commit into
nodejs:mainfrom
GeoffreyBooth:strip-types-twin-dts

Conversation

@GeoffreyBooth

Copy link
Copy Markdown
Member

Alternative to #63853 or #63869

Problem

By default, Node.js refuses to strip types from .ts/.mts/.cts files under node_modules, throwing ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. The restriction protects editor/tsc performance: a dependency that ships raw TypeScript without declarations forces consumers to infer types from its source (“slow types”).

But using the folder as the boundary also blocks legitimate cases where trusted first-party TypeScript ends up under node_modules and does ship declarations:

  • monorepo deploys that copy workspace packages into a real node_modules (pnpm deploy),
  • packages installed from a private registry or a Git URL,
  • globally installed TypeScript CLIs.

Solution

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 sits beside it (foo.d.ts next to foo.ts) — the default layout emitted by tsc --emitDeclarationOnly. The gate is a single stat, and requires type-stripping to be enabled (rejected with --no-strip-types). Otherwise the existing error is thrown.

Each of the blocked workflows already runs a build or publish step, so producing declarations is cheap: the same CI that assembles a pnpm deploy, publishes to a private registry, or packs a global CLI can run tsc --emitDeclarationOnly to emit .d.ts files beside the sources. That one artifact then both satisfies Node’s gate and ships ready-to-consume types, so the raw .ts runs with no separate JavaScript build — which is the thing these users are actually asking for.

It also keeps the TypeScript team’s guarantee intact. The restriction exists to stop a dependency’s raw, undeclared source from dragging down a consumer’s editor and tsc; by tying execution to the presence of a declaration, Node will only run raw .ts from a package that already provides one, so the consumer’s tooling always resolves a .d.ts and never falls back to inferring types across a dependency. The runtime permission is bound to the same artifact that keeps editing fast, rather than to where the code happens to live.

Notes

  • Co-located only. A declaration reachable only through a separate directory (via the exports "types" condition) is not recognized in this initial version; adding that is a small follow-up if the layout proves common.
  • Flagged, but should be unflaggable. It is behind an experimental flag for now to gauge reaction, but the intent is that this should be something that we can enable by default in the future. Since this preserves the gate that requires public registry packages to contain type declarations, it avoids the pollution of the public registry with TypeScript files that lack accompanying .d.ts files.
  • Alternatives considered: module: allow type stripping in node_modules #63853 removes the restriction outright; module: add --experimental-strip-private-modules  #63869 gates on private: true (which can’t cover private-registry packages and doesn’t address editor performance).

@nodejs/typescript @anonrig @marco-ippolito @RyanCavanaugh

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/config
  • @nodejs/loaders
  • @nodejs/typescript

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Jun 16, 2026
@GeoffreyBooth GeoffreyBooth added the module Issues and PRs related to the module subsystem. label Jun 16, 2026
@GeoffreyBooth GeoffreyBooth force-pushed the strip-types-twin-dts branch from e0bbcf0 to 1826070 Compare June 16, 2026 00:36
@anonrig

anonrig commented Jun 16, 2026

Copy link
Copy Markdown
Member

I think this is a far better solution than any alternatives. Thank you.

@anonrig anonrig added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 16, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 16, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

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: nodejs#63853
Refs: nodejs#63869

Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
@GeoffreyBooth GeoffreyBooth force-pushed the strip-types-twin-dts branch from 1826070 to 789b878 Compare June 16, 2026 03:42
@ljharb

ljharb commented Jun 16, 2026

Copy link
Copy Markdown
Member

Anything that allows or encourages publishing of untranspiled TS is a disaster for the ecosystem. At the very least, put this behind a flag?

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@ljharb

ljharb commented Jun 16, 2026

Copy link
Copy Markdown
Member

Also, for d.ts to be present with TS, tsc has been ran. What’s the point of not stripping the types at publish time? The monorepo use case is for no build process - meaning no d.ts files.

@GeoffreyBooth

Copy link
Copy Markdown
Member Author

for d.ts to be present with TS, tsc has been ran. What’s the point of not stripping the types at publish time? The monorepo use case is for no build process — meaning no d.ts files.

Claude: There are two different things being conflated:

The local, no-build monorepo loop already works — and this PR doesn’t touch it. When you run a workspace package in place, the package manager symlinks it into node_modules, and Node resolves through the symlink to the real path in your source tree (it realpaths by default). That real path is outside node_modules, so type-stripping is already allowed today, with no .d.ts required. “Edit .ts, run, no build, no declarations” is the status quo and stays that way.

The restriction only bites when first-party code is copied into node_modules, where the symlink is gone and the real path is genuinely inside node_modules:

  • pnpm deploy / Docker builds that copy workspace packages into an isolated node_modules,
  • packages published to a private registry or installed from a Git URL,
  • globally installed CLIs (npm i -g).

Every one of those is a publish/deploy boundary — a CI or packaging step that already exists. Running tsc --emitDeclarationOnly (or a fast isolatedDeclarations emitter) there is incremental, not a new build process imposed on day-to-day development.

On “why not just emit .js at publish time, then?” — you can, and for many packages that’s the right call. Shipping raw .ts + .d.ts instead buys a few things:

  • What runs in production is what you ran in development. Type-stripping is a whitespace-only transform: it deletes type annotations and leaves the rest of the source byte-for-byte, preserving line and column numbers. No target downleveling, no injected helpers, no module-format rewriting, no bundler — so the executed code cannot diverge from the source the way a full tsc (or bundler) output can. Shipping .js means shipping a different artifact than the one you tested.
  • Debugging lands on the original source — stack traces and “go to definition” point at the .ts you wrote, with no source maps to generate, ship, or keep in sync.
  • One canonical artifact, executed directly by the consumer’s Node, instead of maintaining a separate transpiled output (often dual CJS/ESM).
  • Declaration emit is decoupled from transpilation and can be done very fast via isolatedDeclarations tooling, so “ran tsc“ need not mean “ran a full type-checking transpile.”

@ljharb

ljharb commented Jun 16, 2026

Copy link
Copy Markdown
Member

That doesn’t really explain it. Of those 3 bullet points, only the first is something we’d want to support - the other two we actively want to be UNsupported. Enabling the first use case must not enable others.

@GeoffreyBooth

Copy link
Copy Markdown
Member Author

That doesn’t really explain it. Of those 3 bullet points, only the first is something we’d want to support — the other two we actively want to be UNsupported. Enabling the first use case must not enable others.

If I’m reading you right, your position is that .ts files shouldn’t live in the public registry, so any use case depending on that should stay unsupported. But I think that overlooks why people objected to .ts in the registry in the first place: without accompanying .d.ts files, every consumer’s type-checking takes a large performance hit, because the compiler has to infer types from full source instead of reading declarations. With the .d.ts files present—which this PR requires—that objection goes away, and I don’t see a remaining reason the public registry can’t contain .ts.

The other objections I can find to TypeScript files in the public registry were semver (an author’s newer syntax breaking an older consumer) and tsconfig drift (a consumer’s strict surfacing errors deep inside a dependency). Both are also resolved by requiring co-located .d.ts files: because a declaration is present, a consumer’s tsc/tsserver reads it and never loads the dependency’s .ts implementation into the program, exactly as it treats every typed package on npm today. So the implementation files the compiler would choke on are never parsed or type-checked; from the compiler’s perspective it’s the same as if they were .js files.

So these cases don’t need to be kept apart. TypeScript with co-located declarations is fine in the public registry, so the private-registry, Git-URL, and global-CLI cases, which are all more controlled, are fine too.

@GeoffreyBooth GeoffreyBooth removed the needs-ci PRs that need a full CI run. label Jun 16, 2026
@ljharb

ljharb commented Jun 16, 2026

Copy link
Copy Markdown
Member

Performance is a very minor factor. The issue is that if .ts ends up in the registry, TypeScript will find itself unable to evolve.

d.ts files do not eliminate config drift; each package's config matters quite a lot. DefinitelyTyped mandates that types be config-agnostic, certainly, but no other package has that guarantee.

There is no circumstance where runnable TypeScript in the public registry is a good outcome, full stop.

@aduh95

aduh95 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

d.ts files do not eliminate config drift; each package's config matters quite a lot

@ljharb can you clarify if this PR changes that? I'm not sure if you're saying that's already the sorry state of the ecosystem, or if it's a concern introduced by this PR.

The issue is that if .ts ends up in the registry, TypeScript will find itself unable to evolve.

I've seen this discussion point come up again and again, so there seems to be some communication gap, maybe an example would help get the point across. Is there by any chance a precedent where erasable TS syntax evolved in a non-backward compatible way?

@aduh95 aduh95 added the needs-ci PRs that need a full CI run. label Jun 16, 2026
@targos

targos commented Jun 16, 2026

Copy link
Copy Markdown
Member

a precedent where erasable TS syntax evolved in a non-backward compatible way?

To clarify, you mean an example where old erasable TS syntax is no longer valid in a newer TS version?

@anonrig anonrig added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 16, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 16, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@Renegade334

Copy link
Copy Markdown
Member

If I’m reading you right, your position is that .ts files shouldn’t live in the public registry, so any use case depending on that should stay unsupported. But I think that overlooks why people objected to .ts in the registry in the first place: without accompanying .d.ts files, every consumer’s type-checking takes a large performance hit, because the compiler has to infer types from full source instead of reading declarations. With the .d.ts files present—which this PR requires—that objection goes away, and I don’t see a remaining reason the public registry can’t contain .ts.

A module's emitted .d.ts definition file is ignored by tsc if a .ts file exists for the same module path, so I don't see that this holds true?

@ljharb

ljharb commented Jun 16, 2026

Copy link
Copy Markdown
Member

@aduh95 i'm saying it's a normal and expected state of the ecosystem, and not something that node can possibly affect in any way.

@ljharb ljharb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think any TS-related feature should land over the objections of the TS team; putting a block to ensure this doesn't land until such time as there are confirmed to be none (feel free to dismiss it at that time, if it occurs)

@jakebailey

jakebailey commented Jun 16, 2026

Copy link
Copy Markdown
Member

I don't think that this detection method works. TypeScript prefers .ts files over .d.ts files in many conditions.

For example, a project may be switching to TS from JS or otherwise not set outDir, and therefore have index.ts, index.js, and index.d.ts all next to each other.

If you have that situation (even in node_modules) and don't also use export maps to redirect the types condition to the .d.ts file, the implicit detection is going to choose index.ts when resolving the import.

(rene jinx, oops)

@GeoffreyBooth

Copy link
Copy Markdown
Member Author

I don’t think that this detection method works. TypeScript prefers .ts files over .d.ts files in many conditions.

@Renegade334 @jakebailey Thank you for this; I had always assumed tsc preferred .d.ts files when present, but I just checked and you’re right. And even with a types condition for the package entry point, any relative imports from that entry make tsc read the .ts over the .d.ts again. So there’s no way to make this PR’s approach work.

#63869′s private: true gate covers first-party code, but some use cases aren’t solved by that PR: packages on a private registry, Git dependencies, and globally-installed CLIs.

While looking into this I considered another idea: strip a node_modules .ts only when it’s valid under --isolatedDeclarations, so its exported types are fully explicit and locally resolvable. It’s closely related to the “no-slow-types” check JSR enforces at publish, and it’s what isolatedDeclarations was designed for. In a way it’s more direct than the .d.ts check: it verifies, file by file, that each .ts has fast-to-determine types and so won’t be a performance problem during development; and it doesn’t require emitting any build artifacts, which satisfies the folks who want to publish .ts and avoid build steps. The check is single-file, so Node never has to crawl a package’s graph. SWC (in Amaro) already has experimental isolated-declarations emit (emitIsolatedDts), so this looks like wiring it up rather than building a checker from scratch, though we’d want its diagnostics to track tsc‘s isolatedDeclarations closely, since divergence would be its own problem. Ideally this check would run at publish time as it does for JSR, but since that’s out of our hands, running it at runtime is the next best option (and better than the blanket error today). What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants