Per-Subpath Types and typesVersions Fallbacks
Give every export subpath its own declaration file, and add a typesVersions fallback so consumers on legacy moduleResolution still get types for deep entries.
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
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:
typesVersionsis present, but legacy consumers still reportTS2307for every subpath.Root cause: The keys were written in
exportssyntax with a leading./("./date").typesVersionskeys 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
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
typesfield does not cover subpaths. It answers only the root import for legacy resolvers; every subpath still needs either a condition or atypesVersionsentry. typesVersionscannot 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 owntypesVersionsglob, 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.tsfiles 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.
Related
- Subpath Exports and Deep Import Patterns — the map these declarations attach to.
- Declaration File Generation and Type Stripping — producing the
.d.tsand.d.ctspairs in the first place. - Checking Types with are-the-types-wrong — the tool that reports every mismatch described here.