Skip to content

fix(types): rewrite .d.mts relative specifiers to .mjs#121

Open
cuzzlor wants to merge 5 commits intomainfrom
fix-dmts-mjs-specifiers
Open

fix(types): rewrite .d.mts relative specifiers to .mjs#121
cuzzlor wants to merge 5 commits intomainfrom
fix-dmts-mjs-specifiers

Conversation

@cuzzlor
Copy link
Copy Markdown
Contributor

@cuzzlor cuzzlor commented Apr 25, 2026

Summary

Follow-up to #120. emitDualDeclarations was producing .d.mts files whose relative specifiers ended in .js, which under TS's node16+ ESM resolver pairs them with the adjacent .d.ts. In a dual-published package whose root package.json has "type": "commonjs", that .d.ts is treated as CJS-flavoured — so strict-ESM consumers see type-resolution mismatches against an otherwise-valid package.

rewriteSpecifier is only ever called from the .d.mts branch of emitDualDeclarations, so the extension it emits should be .mjs, which pairs with the .d.mts twin and keeps the resolution chain in ESM throughout. The .d.cts branch is unaffected.

What changed

  • src/util/copy-package-json-from-config.ts — two-character fix in rewriteSpecifier (.js.mjs in both the file and directory branches), plus updated comments explaining the rationale.
  • src/util/copy-package-json-from-config.spec.ts — updated the three existing assertions that pinned the buggy .js output, then added focused coverage for: the file case (was only exercised via the integration test), resolving when only the .d.mts twin exists, and exercising all three module-specifier shapes (from '...', bare import '...', dynamic import('...')) in one pass.
  • readme.md — added a ## copy-package-json subsection covering dual-declaration emission, the per-condition exports shape, and the .mjs rewrite rules with rationale. The original feature shipped without README coverage; this fills that gap.

Discovered via

Building @makerx/graphql-apollo-server@2.0.0-beta.0 against the just-published @makerx/ts-toolkit@5.0.0-beta.0 produced .d.mts files like:

// dist/index.d.mts
export * from './plugins/index.js'   // ← resolves to dist/plugins/index.d.ts (CJS-flavoured)

Strict-NodeNext consumers downstream see mis-resolved declarations. After this fix:

// dist/index.d.mts
export * from './plugins/index.mjs'  // ← resolves to dist/plugins/index.d.mts (ESM)

Why .mjs rather than .js

Under moduleResolution: "node16"/"nodenext", TypeScript pairs each module specifier with the declaration whose extension matches the JS extension:

Specifier in .d.mts Resolves to Module kind in dual-publish dist
'./foo.js' ./foo.d.ts CJS (per package.json type)
'./foo.mjs' ./foo.d.mts ESM

Using .mjs keeps the entire declaration chain in one module kind, so ESM consumers don't accidentally pull in CJS-shaped types.

Test plan

  • npm test — 17/17 pass (was 14, added 3)
  • npm run build — ts-toolkit dogfoods: its own dist/index.d.mts now reads from './util/copy-package-json-from-config.mjs'
  • attw --pack dist — green across node10 / node16 (CJS) / node16 (ESM) / bundler (caveat: attw doesn't actually validate this class of fix, so the byte-level inspection is the real verification)
  • Rebuild a downstream dual-publish consumer (e.g. graphql-apollo-server) against this branch and confirm its .d.mts files now use .mjs specifiers without any local workaround

🤖 Generated with Claude Code

cuzzlor and others added 3 commits April 25, 2026 10:34
`emitDualDeclarations` was producing `.d.mts` files whose relative
specifiers ended in `.js`. Under TS's node16+ ESM resolver, a `.js`
specifier in a `.d.mts` is paired with the adjacent `.d.ts`, which —
in a dual-published package whose root `package.json` has
`"type": "commonjs"` — is treated as CJS-flavoured. Strict ESM
consumers then surface type-resolution errors against an otherwise
valid package.

`rewriteSpecifier` is only ever called for the `.d.mts` branch, so the
extension it emits should be `.mjs`, which pairs with the `.d.mts`
twin and keeps the resolution chain in ESM throughout.

The `.d.cts` branch is unaffected — those remain content-identical
copies of the source `.d.ts`, which CJS resolution handles via
extensionless specifiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tests
- Add focused unit test for the file case (was only exercised via the
  integration test): `from './foo'` → `from './foo.mjs'` when `./foo.d.ts`
  exists.
- Add test for resolving when only the `.d.mts` twin is present alongside
  the source, exercising the second branch of `rewriteSpecifier`.
- Add test that runs all three module-specifier shapes through one pass
  (`from '...'`, bare `import '...'`, dynamic `import('...')`).

Docs
- Add a `## copy-package-json` subsection covering dual-declaration
  emission, the per-condition `exports` shape, and the `.mjs` rewrite
  rules with the rationale for `.mjs` over `.js`.
- The original feature (PR #120) shipped without README coverage; this
  fills that gap alongside the specifier-extension fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Publishes the .d.mts specifier-extension fix so downstream packages can
drop their local workarounds (e.g. graphql-apollo-server's
build:4a-fix-dmts post-step).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes dual-declaration emission so .d.mts files rewrite extensionless relative specifiers to .mjs (instead of .js), ensuring TypeScript’s node16+/NodeNext resolver pairs ESM declarations with their .d.mts twins rather than adjacent CJS-flavoured .d.ts.

Changes:

  • Update rewriteSpecifier to emit .mjs (and /index.mjs) for resolved relative specifiers in .d.mts output.
  • Expand/adjust unit + integration test assertions to lock in .mjs rewriting behavior and cover additional specifier shapes.
  • Add README documentation for copy-package-json dual type declaration emission and the .mjs rewrite rationale.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/util/copy-package-json-from-config.ts Switches relative specifier rewrite target from .js to .mjs for .d.mts generation and updates inline rationale/comments.
src/util/copy-package-json-from-config.spec.ts Updates assertions to expect .mjs and adds focused tests around file/directory resolution and specifier forms.
readme.md Documents dual declaration emission and the .mjs rewrite rules/rationale.
package.json Bumps version to 5.0.0-beta.1.
package-lock.json Updates lockfile version metadata to 5.0.0-beta.1.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread readme.md Outdated
Comment thread src/util/copy-package-json-from-config.ts Outdated
cuzzlor and others added 2 commits April 25, 2026 10:59
Resolves the moderate XSS advisory (PostCSS Stringify Output unescaped
`</style>`) reaching us via vitest > vite > postcss in dev. Within the
existing semver range; no package.json change, lockfile-only.

Surfaced by the PR check on #121.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Copilot review comments on #121. `rewriteSpecifier` already
resolves both `.d.ts` and `.d.mts` adjacents in the directory branch,
but the README bullet and the source-comment example only mentioned
`.d.ts`. Updated both to spell out either form.

Also adds a sibling unit test covering the case where only an
`index.d.mts` is present in a directory (matching the existing
"only a .d.mts twin alongside the source" test for the file case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants