Skip to content

typescript: resolve imports the way Deno does in a Deno project (#1984) - #1985

Merged
swapnilpaliwal-sd merged 1 commit into
0.2.1from
feat/deno-module-resolution
Oct 24, 2026
Merged

swapnilpaliwal-sd merged 1 commit into
0.2.1from
feat/deno-module-resolution

Conversation

@swapnilpaliwal-sd

Copy link
Copy Markdown
Contributor

Fixes #1984

What changes

A TypeScript file under a deno.json / deno.jsonc now resolves its imports the way Deno does, before the compiler's own resolution is asked:

specifier resolves to resolutionKind
import-map entry onto a project file ("@/": "./src/", imports, scopes, an importMap file) the file PATHS_ALIAS
workspace member by name (@scope/member, @scope/member/sub, also jsr:@scope/member) the file its exports names WORKSPACE_PACKAGE
jsr:@scope/pkg@^1/sub (directly or through the map; a package key also serves its subpaths) the version the lockfile pins (or the highest cached one in range), the file its exports names JSR_PACKAGE (new)
npm:pkg@^3/sub node_modules when installed there, else Deno's npm cache, through the package's exports / types NODE_MODULES_* / DENO_NPM_CACHE (new)
https: URL the downloaded module, following cached redirects and x-typescript-types REMOTE_MODULE (new)

jsr: and URL modules live in Deno's cache as hashed, extensionless files. Each one a project imports is copied, with everything it imports, into a per-project mirror laid out exactly as Deno's vendor/ is (jsr.io/@scope/pkg/1.2.3/mod.ts). An import then resolves to a real .ts file, and the mirror stages as one library whose module paths carry package and version, so two packages' mod.ts never collide in the engine's path match.

--library auto on a Deno project now stages the mirror and every npm: package the project imports, found by the parser's own resolver (parser/dist/deno-libraries.js), so the files staged are the files the imports name.

Also:

  • deno.json / deno.jsonc mark a TypeScript project, so a Deno workspace is one project, not one per member.
  • A Deno file no tsconfig claims compiles under Deno's defaults (ESNext, Bundler, strict, and the config's own compilerOptions); tsConfigPath stays empty.
  • A barrel that re-exports a workspace member by name links to the member's module.
  • vendor/ beside a "vendor": true config is not walked as project source.
  • A file that two programs in one run would both take (a Deno config that maps into an outer program while a nested tsconfig's program also imports it) is analysed once. It was written twice under one primary key.
  • npm: and jsr: specifiers name their package (zod, not npm:zod@^3); a URL or another scheme (bun:test, cloudflare:workers) names none.
  • An edit to deno.json, deno.jsonc or deno.lock marks the graph stale, and with libraries on, re-stages them.

Outside a Deno project the resolver answers nothing.

Parser only plus the plugin's library discovery; no .dl file changes. The parser version bump stays with #1966, whose README entry should mention this.

Measured

Against Deno's own resolver, every import of every file of a public Deno web app (67 files; import map, jsr:, npm:, URL prefixes, "lock": false), compared with deno info --json:

agrees with Deno unresolved
0.2.1 67 / 333 266
this branch 333 / 333 0

The same app end to end, axiomcode index --library auto:

tier 0.2.1 this branch
known_edge 198 346
boundary_lib 0 257
ambiguous_unknown 1001 596
callback_registered 139 139
libraries staged none found 1 mirror (667 files) + 5 npm packages
first index 8 s 66 s (libraries compiled once)
rebuild after an edit 18.5 s, no library recompiled

Controls, four Node TypeScript projects from the dev corpus, full IR diffed old parser against new:

  • two with no Deno config anywhere: byte-identical IR
  • one with Deno examples (deno.json mapping to esm.sh URLs that are not downloaded): 3 import rows change, only packageName (cloudflare:workers and the two URL-mapped specifiers now name no package)
  • one monorepo holding Deno adapters: order-independent diff touches only imports (52 rows: Deno import maps now resolve to the jsr: packages they name, npm:x names x, bun:test names no package), modules (14 rows: Deno files record BUNDLER), and 3 package-entry rows the engine ignores (NO_SOURCE_MODULE). Duplicate module keys: 0. Parse time unchanged.

A Node project using the same web framework from node_modules resolves the same library calls as its Deno twin: the calls still left unresolved on the framework's generic app type are left unresolved on Node too, and are engine work, not resolution.

Tests

  • parser/src/test/deno-tests.ts (new, 9 checks, registered in axiomcode test parser): semver ranges; import map keys, prefixes, package subpaths, scopes and an importMap file; workspace members, member map over root map, one project per workspace; jsr: locked version, exports and the transitive copy into the mirror; jsr: with no lockfile (highest cached, yanked skipped); npm: through node_modules and Deno's cache with exports; URL redirects, x-typescript-types and an export * as re-export (which the compiler's light pre-processor does not report); no answer and no mirror write outside a Deno project; extraction rows, links, re-export link and vendor/ exclusion. Built on a fake $DENO_DIR in Deno 2's layout: no Deno, no network.
  • typescript-tests 49/49, javascript-tests 49/49, discovery-tests all passed.
  • graph/test/typescript/run-tests.sh: passed 101, failed 0.
  • tests/manifests.py ok. tests/freshness.py 117 of 118, the same single failure as unmodified 0.2.1 (prune: Main.JS and Calc.CS count as source, case folding on macOS).
  • OSS scrub gate: 0 hits on every changed file.

Not in this change

  • .js files in a Deno project still resolve through the JavaScript front end's own resolution.
  • A jsr: or URL module that was never downloaded resolves to nothing (honest for this machine): deno install first.

A file under a deno.json / deno.jsonc now resolves its imports through Deno's
rules before the compiler's: the import map (imports, scopes, an importMap file,
and a package key serving its subpaths), workspace members by name through their
exports, jsr: packages at the version the lockfile pins (or the highest cached
one in range), npm: packages in node_modules or Deno's npm cache, and https:
modules as downloaded, following redirects and x-typescript-types.

jsr: and URL modules are copied from Deno's cache, with everything they import,
into a per-project mirror laid out as vendor/ is, so an import resolves to a
real .ts file and the mirror stages as one library whose module paths carry the
package and version. `--library auto` finds the mirror and the npm packages a
Deno project imports through the parser's own resolver (dist/deno-libraries.js).

Also: deno.json marks a TypeScript project (a workspace is one project); Deno
files with no tsconfig compile under Deno's defaults (ESNext, Bundler, strict);
a barrel re-exporting a workspace member by name links; vendor/ beside a
"vendor": true config is not walked as project source; a file two programs
would both take is analysed once; npm:/jsr: specifiers name their package and
URL or scheme specifiers name none; a deno.json or deno.lock edit marks the
graph stale.

Outside a Deno project nothing changes: the resolver answers nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@swapnilpaliwal-sd

Copy link
Copy Markdown
Contributor Author

Second real project: a 157k-line Deno workspace monorepo (1,856 TypeScript files, six workspace members, jsr: and npm: through the root import map, a lockfile).

0.2.1 this branch
imports UNRESOLVED 1,680 of 7,949 0 (852 DENO_NPM_CACHE, 828 JSR_PACKAGE)
150-file sample vs deno info --json 531 / 661 660 / 661 (the one left is the comparison script double-counting a .json extension, identical on both)
boundary_lib 0 9,885 (5,240 into jsr: packages, 4,608 into the TypeScript standard library, 37 npm)
ambiguous_unknown 35,821 26,563
known_edge 7,409 7,410
callback_registered 2,157 2,111
parse 32 s 31 s
first index --library auto 1:42 3:05 (libraries compiled once)

The 46 callback_registered edges that went were wrong: in const x = arr.find((v) => ...); Array.isArray(x) the arrow was credited to isArray on the next line. With the standard library staged that call binds to ArrayConstructor.isArray, and the find callback edge stays. Twelve random new library edges checked by hand: all land on the pinned version's declaration.

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.

1 participant