A dependency imports fine at runtime, but `tsc` reports TS2307 "Cannot find module 'pkg/utils' or its corresponding type declarations" — the package exposes that subpath only through an `exports` map in its package.json, and the tsconfig sets `"moduleResolution": "node10"`. What is going on, and what do `node16`/`nodenext` and `bundler` change?
answer
- one mode predates a package.json field
- directory walking versus an explicit map
- conditions are matched in order
- one mode is only legal with ESM output
- the checker and the loader disagree
basics
~20 snode10 is the legacy resolution algorithm: it walks node_modules by directory and file name and ignores package.json exports entirely, so a subpath declared only there is invisible to the compiler. node16, nodenext and bundler read exports maps.
solid answer
~50 s`moduleResolution: "node10"` models the pre-`exports` world: it turns a specifier into a directory or file path, honouring `main`, `types` and index files, and it does not consult `exports` or `imports` at all. A modern package that exposes `pkg/utils` only through its `exports` map therefore has no `node_modules/pkg/utils` path for the old algorithm to find, which is exactly the diagnostic you are seeing — while the actual runtime, which does read `exports`, has no trouble. `node16` and `nodenext` implement the modern algorithm: they read `exports`/`imports`, pick conditions based on the importing file's module format, and require explicit file extensions on relative specifiers in ESM files. `bundler` also reads `exports` but resolves the way bundlers do — import-style conditions, no extension requirement — and it is only permitted alongside an ES-module or `preserve` `module` setting. Pick by what resolves at runtime.
code
json · 10 lines{
"name": "pkg",
"exports": {
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
}
}go deeper
Know that moduleResolution tells the compiler how to turn an import specifier into a file, and that the older mode cannot see subpaths a package declares through its exports map.
Explain the concrete differences: node10 walks directories and ignores exports; node16 and nodenext read exports, match conditions by the importing file's format, and require file extensions in ESM; bundler reads exports without the extension rule.
Diagnose from the asymmetry — runtime fine, checker failing means a resolution-mode mismatch — and argue that the deeper risk of node10 is checking against declarations for a build you are not actually running.
Own the migration: moving a large repo to node16 or nodenext surfaces extension errors and condition-dependent declaration changes at once, so plan the sequencing, the shared base config, and the rule for which packages may deviate.
## The two eras of resolution The original algorithm was structural: to resolve `pkg/utils`, look for `node_modules/pkg/utils.js`, then `node_modules/pkg/utils/index.js`, and so on up the directory tree. Any file inside a package was reachable by its path. `moduleResolution: "node10"` — the mode that used to be spelled `"node"` — is TypeScript's model of that era, with declaration files substituted for JavaScript ones (`utils.d.ts`, `index.d.ts`, the `types` field of package.json). The modern era added `exports` to package.json: an explicit map from public subpaths to files, which also *encapsulates* the package — anything not listed becomes unreachable, even if the file is right there on disk. `node10` does not read that map. So it fails in two opposite directions: it cannot find subpaths that exist only as `exports` entries, and it happily resolves internal files the package never meant to expose. ## What each mode does **`node10`** — legacy directory walk. Honours `main` and `types`/`typings`, index-file fallback, `@types` lookup. Ignores `exports` and `imports`. Its only remaining justification is a project that genuinely still resolves under those rules, or an old codebase not yet migrated. **`node16` / `nodenext`** — the modern algorithm. These read `exports` and `imports`, and crucially they make resolution *depend on the importing file's module format*. A file is ESM or CommonJS according to the nearest package.json `type` field and the `.mts`/`.cts` extensions; from an ESM file the `import` condition matches, from a CommonJS file the `require` condition does. They also enforce the ESM rule that relative specifiers carry explicit file extensions. `nodenext` tracks the latest behaviour as it evolves; `node16` pins to the semantics of that release line. **`bundler`** — reads `exports` like the modern modes, but drops the parts that are specific to a runtime's own loader: no mandatory file extensions on relative imports, and conditions resolved the way a bundler resolves them rather than by the importing file's format. Because it assumes a bundler will do the real resolution, TypeScript only allows it with `module` set to `preserve` or an ES-module setting; combining it with `module: commonjs` is rejected outright. ## The `types` condition Within an `exports` map, TypeScript looks for a `types` condition to find declarations: ```json { "exports": { "./utils": { "types": "./dist/utils.d.ts", "import": "./dist/utils.mjs", "require": "./dist/utils.cjs" } } } ``` Conditions are matched **in order**, so `types` must be listed before `import`, `require` and `default` — a map that puts it last will never match it, and the package looks untyped to the modes that read `exports` at all. Under `node10` none of this is visible; the historical workaround packages used for that mode is the `typesVersions` field, which maps subpaths to declaration files in a way the old algorithm can follow. ## Diagnosing the reported failure The telltale is precisely the asymmetry in the question: the runtime is satisfied and the checker is not. That combination points at a resolution-mode mismatch, not at a missing dependency. The checks worth running, in order: does the package.json have an `exports` map; is the subpath listed there; is there a `types` condition and is it first; and what `moduleResolution` is actually in effect (it has a default derived from `module`, so a project that never set it explicitly may be on `node10` without anyone deciding that). The fix is to move to a mode that matches reality. If the emitted files are executed by a runtime implementing the modern algorithm, that is `node16` or `nodenext` — which also brings the extension requirement and the format-sensitive conditions, so expect a batch of new errors on relative imports the first time. If a bundler resolves them, `bundler` is the honest description of what happens and avoids imposing the extension rule on code no runtime loads directly. ## Why this matters beyond the error message Resolution mode determines *which declaration file* the checker reads, and a dual-published package can legitimately ship different declarations for its ESM and CommonJS entry points. Under `node10` you get whatever `types` points at, regardless of how the module is actually loaded — so the checker may be describing the build you are not running. That is the deeper reason to move off it: not the error, but the silent possibility of checking against the wrong file.
- Under node16 or nodenext, why can the same specifier resolve to different files depending on which file imports it?Because conditions are selected by the importing file's module format. An ESM file matches the `import` condition, a CommonJS file matches `require`, and format is decided by the nearest package.json `type` field or an `.mts`/`.cts` extension. A dual-published package can therefore hand the checker genuinely different declarations for the two entry points.
- Why does TypeScript refuse moduleResolution "bundler" together with module "commonjs"?Because bundler resolution describes how a bundler finds modules — ESM-style specifiers, no extension requirement — and that is inconsistent with emitting `require()` calls resolved by a runtime's own algorithm. The compiler only permits `bundler` with `preserve` or an ES-module `module` setting, so the resolution model and the emitted module system stay in agreement.
- A package ships declarations but they are invisible under node16 while the JavaScript resolves fine. What is the likely cause?Its `exports` map lists the `types` condition after `import`, `require` or `default`. Conditions match in order, so an earlier match wins and the declarations are never selected. Fixing it is a change to the package: `types` must be the first condition in each entry.
saying these in an interview costs you the question
- Thinks node10 reads package.json exports maps
- Assumes a green runtime import proves the checker can resolve it
- Believes bundler mode is just a faster node16
- Puts the types condition last in an exports map
- Treats moduleResolution as unrelated to which .d.ts is checked