Skip to content

[Feature] The build tsconfig resolves modules as Bundler rather than NodeNext, so nothing validates the emitted specifiers under the resolver the package actually publishes to #1008

Description

@pathosDev

Use case

The build tsconfig resolves modules as if a bundler will run:

tsconfig.json:2-6
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],

No bundler runs. bunx tsc emits ESM straight into dist/, and the consumer is Node ≥ 24
or Deno resolving those files through the real ESM resolver. Under Bundler, TypeScript
deliberately relaxes exactly the rules that resolver enforces: extensionless relative
specifiers are accepted, directory-index resolution is accepted, and exports-map-aware
.d.ts resolution is not modelled. The compiler is therefore validating the emitted
specifiers against a set of rules that nothing downstream applies.

Today that costs nothing, and it is worth being precise about why: the project's mandatory
.js-suffix convention (AGENTS.md, Code style) happens to satisfy every rule
NodeNext would add. That is a convention held by review discipline, with no compiler
behind it. The guard is one forgotten suffix away from mattering, and the failure mode is
a runtime ERR_MODULE_NOT_FOUND in a consumer's process rather than a compile error here.

A correction to the original review note, which claimed NodeNext would have turned
W9-01 and W9-02 into compile errors: it would not. Both live in docs/**, and the build
tsconfig's include is ["src/**/*.ts"] — documentation code fences are outside every
tsconfig in the repo. That gap is W9-03, and it is not addressed by changing a resolution
mode.

Proposed shape

Switch the build to "module": "NodeNext" + "moduleResolution": "NodeNext".

The change is currently free — see Verification, it compiles clean — which is the
argument for doing it now rather than after the first violation. What it buys:

  • Relative specifiers are checked against the resolver that will actually run them, so a
    missing .js becomes TS2835 at build time instead of a consumer-side runtime failure.
  • .d.ts resolution becomes exports-map-aware, which matters once [Feature] Add subpath exports to package.json #414 introduces
    subpaths: NodeNext verifies that a subpath's types condition resolves, Bundler
    does not.
  • The compiler's view matches attw --profile esm-only, which already checks the
    emitted package under node16/ESM. Two tools currently disagree about which resolver
    is authoritative; NodeNext makes them agree.

Two things to watch when landing it:

  • NodeNext implies module: NodeNext, which changes how import type elision and
    verbatimModuleSyntax interact. verbatimModuleSyntax is currently false
    (tsconfig.json:24), so this is likely inert, but it should be checked rather than
    assumed.
  • tsconfig.dev.json, tsconfig.bench.json and devtools-ui/tsconfig.json extend or
    parallel the build config; they need the same treatment or an explicit override, or
    typecheck:dev will diverge from typecheck.

Low priority, and explicitly a guard rather than a fix: there is no current defect behind
it.

Acceptance

  • tsconfig.json sets module and moduleResolution to NodeNext.
  • bun run typecheck is green, and dist/ is unchanged apart from any deliberate specifier fixes.
  • bun run smoke still passes on Bun, Node and Deno.
  • The sibling tsconfigs (dev, bench, devtools-ui) are consistent with the build config.
  • A deliberately extensionless relative import fails bun run typecheck.

Verification status

Confirmed by reading for the configuration; the claimed consequence was tested and
does not hold.

The config fact is quoted verbatim above. To establish whether the relaxation is currently
masking anything, the source tree was compiled under the strict resolver:

$ bunx tsc --noEmit --module nodenext --moduleResolution nodenext
EXIT=0
$ bunx tsc --noEmit --module nodenext --moduleResolution nodenext --listFilesOnly | grep -c 'src/'
550

All 550 source files resolve cleanly under NodeNext — so the .js-suffix convention is
being held perfectly today, and this issue is hardening with no latent defect behind it.
The note's claim that NodeNext would have caught W9-01/W9-02 is refuted: those are
in docs/**, which no tsconfig includes.

git status --porcelain is clean afterwards.

Related: #414 (subpath exports) is where exports-aware .d.ts resolution starts to
earn its keep. W9-03 is the gap that would actually have caught W9-01 and W9-02.

Part of the production-readiness review batch — tracked in #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestinfrastructureCI / build / live-integration testspriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions