A package can have perfectly working subpath exports at runtime and still look untyped to half its consumers. The report usually arrives as a screenshot of a red squiggle:

error TS2307: Cannot find module '@scope/my-library/date' or its corresponding
type declarations.

Two independent causes produce that identical message: a subpath entry with no types condition, and a consumer on moduleResolution: "node" whose compiler never reads the exports map at all. This guide fixes both, and shows how to tell which one you are looking at.


Root Cause

TypeScript resolves a package’s types by walking the same condition object the runtime walks, stopping at the first matching key — so a types condition placed after import, or omitted from a subpath entry, is simply never consulted. That accounts for the modern half of the problem.

The legacy half is different in kind. Under moduleResolution: "node" — the pre-4.7 algorithm still configured in many long-lived applications — the compiler ignores exports entirely and looks for a physical file: node_modules/@scope/my-library/date.d.ts, or date/index.d.ts. Your exports map is invisible to it, so no amount of condition ordering helps. The typesVersions field exists precisely to give that resolver a map it does understand.


Minimal Reproduction

{
  "name": "@scope/my-library",
  "type": "module",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "default": "./dist/index.mjs"
    },
    "./date": {
      "import": "./dist/date.mjs",
      "default": "./dist/date.mjs"
    }
  },
  "files": ["dist"]
}
// consumer.ts
import { createClient } from "@scope/my-library";        // typed
import { formatISO } from "@scope/my-library/date";      // TS2307, at runtime it works fine

The ./date entry has runtime conditions and no types condition. Node.js resolves it; TypeScript does not.


Two Resolvers, Two Maps

Which map each TypeScript resolver reads Under node16, nodenext or bundler resolution TypeScript reads the types condition in the exports map. Under the legacy node resolution mode it ignores exports and looks at typesVersions or for a physical declaration file at the subpath. The same import, resolved by two different algorithms node16 / nodenext / bundler reads: exports map needs: a types condition per subpath condition order matters the majority of consumers and the shape to optimise for legacy "node" resolution reads: typesVersions, then files ignores: the exports map entirely needs: a glob-to-path mapping a shrinking but real audience cheap to support, easy to forget

Step-by-Step Fix

1. Give every subpath entry its own types condition, first

     "./date": {
+      "types": "./dist/date.d.ts",
       "import": "./dist/date.mjs",
       "default": "./dist/date.mjs"
     }

For a dual-format package the condition needs to be nested so each format gets the declaration file that matches it:

{
  "exports": {
    "./date": {
      "import": { "types": "./dist/date.d.ts", "default": "./dist/date.mjs" },
      "require": { "types": "./dist/date.d.cts", "default": "./dist/date.cjs" }
    }
  }
}

Expected result: attw --pack . shows a row for @scope/my-library/date with no problems under node16 (both ESM and CJS) and bundler.

2. Confirm the declaration files actually ship

A types condition pointing at a file excluded from the tarball fails in precisely the same way as a missing condition, and looks identical from your working directory.

npm pack --dry-run --json | node -e '
  const files = new Set(JSON.parse(require("fs").readFileSync(0,"utf8"))[0].files.map(f => f.path));
  const pkg = require("./package.json");
  const refs = JSON.stringify(pkg.exports).match(/\.\/[\w./-]+\.d\.[cm]?ts/g) ?? [];
  const missing = [...new Set(refs)].map(r => r.slice(2)).filter(p => !files.has(p));
  console.log(missing.length ? "MISSING: " + missing.join(", ") : "all declaration files ship");
'

3. Add a typesVersions fallback for legacy resolution

typesVersions maps a TypeScript version range to a set of path rewrites. Note the syntax: keys are globs ending in *, values are arrays of replacement paths, and the star is substituted positionally.

{
  "typesVersions": {
    "*": {
      "date": ["./dist/date.d.ts"],
      "plugins/*": ["./dist/plugins/*.d.ts"],
      "*": ["./dist/*.d.ts"]
    }
  }
}

The three entries cover the three shapes: an exact subpath, a pattern-managed directory, and a catch-all. Order matters here — TypeScript takes the first matching key — so the catch-all belongs last.

HAZARD PREVENTION

Symptom: typesVersions is present, but legacy consumers still report TS2307 for every subpath.

Root cause: The keys were written in exports syntax with a leading ./ ("./date"). typesVersions keys are not relative specifiers; they are bare subpath globs, and a leading ./ prevents any match.

Fix: Strip the ./ from every key, keep it on every value, and re-check with a consumer project pinned to "moduleResolution": "node".

4. Verify with a consumer in each resolution mode

Three consumer configurations to verify Verifying under nodenext with ESM exercises the types condition in the import branch, nodenext with CommonJS exercises the require branch declaration file, and legacy node resolution exercises the typesVersions fallback. Each configuration proves a different half of the mapping nodenext, ESM exercises: import branch expects: .d.ts the default assumption most consumers land here nodenext, CommonJS exercises: require branch expects: .d.cts catches shared declaration files across both formats legacy node exercises: typesVersions expects: glob rewrite the only check that proves the fallback works at all

Verification

d=$(mktemp -d) && npm pack --silent | xargs -I{} mv {} "$d/" && cd "$d"
npm init -y >/dev/null && npm install ./*.tgz typescript --silent

cat > probe.ts <<'TS'
import { formatISO } from "@scope/my-library/date";
const s: string = formatISO(new Date());
TS

for mode in nodenext bundler node; do
  npx tsc --noEmit --strict --module esnext --moduleResolution "$mode" probe.ts \
    && echo "types ok under $mode" || echo "TYPES BROKEN under $mode"
done

Expected: three types ok under … lines. A failure under node alone means the typesVersions block is wrong; a failure under nodenext alone means a types condition is missing or out of order.


Edge Cases / Gotchas

  • A top-level types field does not cover subpaths. It answers only the root import for legacy resolvers; every subpath still needs either a condition or a typesVersions entry.
  • typesVersions cannot express conditions. Legacy consumers get one declaration file regardless of module format, so point the fallback at the ESM declarations and accept the small inaccuracy — it is strictly better than no types.
  • Pattern exports have no automatic type coverage under legacy resolution. A plugins/* entry needs its own typesVersions glob, written in the different syntax, or every plugin is untyped for that audience.
  • Flattened declaration bundles change the file list. If you switch to a declaration bundler, the per-subpath .d.ts files may be replaced by a single file; update both maps in the same commit or one of them will point at files that no longer exist.
  • Editors cache resolution aggressively. After changing either map, a consumer may need to restart the TypeScript server before the fix appears, which makes “it still does not work” reports unreliable for the first few minutes.

Frequently Asked Questions

Why do my subpath types work locally but not for consumers?

Locally the compiler sees your source tree and resolves types through your own include list rather than through the package manifest. A consumer has only the tarball, so their compiler follows the types condition of the matching entry — and if it is missing or points at an unpublished file, the subpath is untyped for them and fully typed for you.

Is typesVersions still needed in 2026?

Only for consumers on moduleResolution: "node", the legacy mode that ignores exports. That population is shrinking but over-represented among large applications that have not migrated their build, and a typesVersions block costs a few lines while affecting nobody on a modern mode.

Does typesVersions support the same wildcard syntax as exports?

No. It uses a path-mapping syntax closer to paths: keys are bare globs with a trailing star, values are arrays of replacement paths. An exports-style pattern copied into typesVersions matches nothing.

Should the types condition point at a .d.ts or a .d.cts file?

It depends on the runtime file served under the same condition — .d.ts for the ESM branch, .d.cts for the CommonJS branch. Sharing one declaration file across both is the mistake attw reports as a masquerading module.

Can I generate the per-subpath declaration files automatically?

Yes. Emitting declarations with tsc from the same entry list your build uses produces one file per entry point, and tools such as tsup or rollup-plugin-dts can flatten each entry into a self-contained declaration file, which also removes internal path references from the published types.



↑ Back to Subpath Exports and Deep Import Patterns