Configuring typesVersions for Older TypeScript Consumers
A package that relies on exports for its type entry points looks broken to every consumer still compiling with moduleResolution: "node" (now called node10): their editor reports Cannot find module 'your-lib/utils' or its corresponding type declarations even though the runtime import works. The typesVersions field is the compatibility shim that gives those consumers a second route to the same declarations. This guide explains when you need it, how to write a map that mirrors your exports, and how to verify both resolution modes before publishing.
Exact symptoms and error messages
The failure only shows up in type-checking, never at runtime, and only for consumers on the legacy resolver. A consumer using a subpath entry sees:
src/app.ts:3:26 - error TS2307: Cannot find module 'your-lib/utils' or its corresponding type declarations.
3 import { slugify } from 'your-lib/utils';
~~~~~~~~~~~~~~~~
If the root entry has no top-level types field either, the root import fails too, or silently degrades to any when skipLibCheck and implicit-any settings hide it:
error TS7016: Could not find a declaration file for module 'your-lib'.
'/app/node_modules/your-lib/dist/index.js' implicitly has an 'any' type.
The same package type-checks cleanly for a consumer using "moduleResolution": "bundler" or "node16", which is what makes the report confusing: two teams install the identical version and only one of them sees errors. When you ask for the consumer's tsconfig.json, you will find "moduleResolution": "node" or a "module": "commonjs" setting that implies it.
Root cause analysis
TypeScript's legacy node10 resolver predates the exports field. It resolves your-lib/utils the old way: look for node_modules/your-lib/utils.d.ts, then utils/index.d.ts, then a nested package.json with a types field. If your declarations live under dist/ and are only reachable through the exports map, none of those paths exist. The newer resolvers read exports and its types conditions, which is why they succeed. The mechanics of both resolution modes are covered in TypeScript Declaration Publishing.
typesVersions was designed for a different job — serving different declarations to different TypeScript versions — but its path-mapping behaviour makes it the standard fix. When TypeScript resolves a module inside your package, it checks typesVersions, picks the first version range that matches the compiler, and rewrites the requested path through the mapping before looking on disk. Crucially, the newer resolvers ignore typesVersions whenever an exports field exists and supplies types, so the shim only affects the consumers who need it.
Resolution and configuration patch
Mirror every public subpath of your exports map into a typesVersions wildcard entry. Keep a top-level types field for the root entry as well.
{
"name": "your-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
},
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"utils": ["./dist/utils.d.ts"],
"*": ["./dist/*"]
}
}
}
Implementation steps:
- Add a top-level
typesfield pointing at the root declaration.node10reads it for bareimport 'your-lib'lookups. - Add
typesVersionswith a"*"version range. The outer key is a semver range matched against the consumer's TypeScript version;"*"means every version. - Map each subpath without the leading
./. Keys intypesVersionsare relative to the package root but written asutils, not./utils— the most common mistake in hand-written maps. - Add a catch-all
"*": ["./dist/*"]last so that any other declared entry resolves by convention, and so TypeScript can follow relative references between declaration files. - Rebuild and pack, then check both resolvers against the tarball rather than the workspace.
Older TypeScript versions and the version range key
If you publish declarations that use syntax newer TypeScript versions understand — for example, const type parameters from 5.0 — you can use real version ranges to serve downlevelled declarations to old compilers. Tools such as downlevel-dts generate the older set:
{
"typesVersions": {
"<5.0": { "*": ["./dist/ts4.9/*"] },
"*": {
"utils": ["./dist/utils.d.ts"],
"*": ["./dist/*"]
}
}
}
TypeScript picks the first matching range in object order, so always put the narrow, older ranges first and the "*" fallback last. Most libraries do not need this; declare a supported TypeScript floor in your README and keep a single map.
Worked example: a library with five entry points
Consider a utility library that exposes a root entry plus four feature subpaths — your-lib/string, your-lib/array, your-lib/date and your-lib/testing — and ships a separate testing build that should never be bundled into production code. The exports map lists all five with types, import and require conditions. Hand-maintaining the matching typesVersions block is where drift creeps in: someone adds your-lib/object to exports, forgets the second map, and node10 consumers lose types for the new entry while every modern consumer is fine. Nobody on the team notices because every internal project uses bundler resolution.
The robust pattern is to treat exports as the single source of truth and derive the shim during the build:
// scripts/sync-types-versions.mjs
import { readFile, writeFile } from 'node:fs/promises';
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const map = {};
for (const [key, value] of Object.entries(pkg.exports)) {
if (key === '.' || key === './package.json' || typeof value !== 'object') continue;
if (value.types) map[key.slice(2)] = [value.types];
}
map['*'] = ['./dist/*'];
pkg.typesVersions = { '*': map };
await writeFile('package.json', JSON.stringify(pkg, null, 2) + '\n');
Run it as part of the prepack step so the published manifest always matches. The script strips the ./ prefix that typesVersions keys must not have, skips entries without a types condition, and appends the catch-all last. Because it runs from prepack, it also runs during npm pack in your validation job, so the fixture tests exercise exactly the manifest that will be published.
How package managers and bundlers interact with the shim
typesVersions is read only by the TypeScript compiler and editors built on its language service; Node.js, npm, pnpm, Yarn and every bundler ignore it. That makes it safe to add — it cannot change runtime behaviour — but it also means runtime tests will never catch a mistake in it. Only type-level checks do.
Two interactions still matter. First, if you use a declaration bundler such as API Extractor or tsup --dts and emit a single rolled-up file per entry, point the typesVersions targets at those rolled-up files rather than at the unbundled tree, or the legacy resolver may find declarations that reference files you did not ship. Second, check the files allowlist: the catch-all ./dist/* mapping is useless if the declaration files are excluded from the tarball, and npm pack --dry-run is the fastest way to confirm they are present.
A final subtlety concerns the root entry. typesVersions can map "." in theory, but TypeScript's legacy resolver looks at the top-level types field before it consults typesVersions for the package root, so relying on a "." key is fragile. Keep the top-level types field and reserve the map for subpaths.
Deciding whether to keep supporting node10 consumers
Every compatibility shim is a maintenance cost, so it is fair to ask whether you need this one at all. The node10 resolver is still what many consumers get implicitly: a tsconfig.json with "module": "commonjs" and no explicit moduleResolution resolves as node10, and a large share of back-end services and older front-end apps are configured exactly that way. Dropping typesVersions therefore breaks real users even though their setup is officially legacy.
A practical policy is to keep the shim while it can be generated automatically — as in the worked example above — and to drop it only in a major release announced in the changelog, alongside any other breaking changes to entry points. If your package is ESM-only, the calculation changes: node10 consumers cannot load an ESM-only package from CommonJS output anyway, so types for them add little value and you can omit the map.
CLI validation and debug commands
Test the packed tarball under each resolver. A small fixture with one tsconfig per mode keeps it honest:
npm pack
mkdir -p /tmp/ts-fixture && cd /tmp/ts-fixture
npm init -y >/dev/null && npm install typescript /path/to/your-lib-2.1.0.tgz
printf "import { slugify } from 'your-lib/utils';\nimport { run } from 'your-lib';\n" > index.ts
# Legacy resolver
npx tsc --noEmit --module commonjs --moduleResolution node10 index.ts
# Modern resolvers
npx tsc --noEmit --module nodenext --moduleResolution nodenext index.ts
npx tsc --noEmit --module esnext --moduleResolution bundler index.ts
When a lookup fails, ask the compiler to narrate the resolution:
npx tsc --noEmit --moduleResolution node10 --traceResolution index.ts | grep -A6 "your-lib/utils"
Look for the line 'package.json' has a 'typesVersions' entry '*' that matches compiler version, followed by the rewritten path. If you do not see it, the map key is wrong. The Are the Types Wrong CLI automates the whole matrix: npx @arethetypeswrong/cli --pack . prints a table with a node10 column, and a red cell there is exactly the problem this page fixes. See Checking Published Types with Are the Types Wrong for reading its output.
Prevention and CI/CD guardrails
- Generate the map instead of hand-writing it. A 20-line build script that reads
exportsand writes matchingtypesVersionsentries removes the drift between the two fields. - Run Are the Types Wrong on every release, and fail on any
node10problem you have promised to support. - Keep the fixture consumers in the repo. A
test/types-consumers/directory withnode10,node16andbundlerprojects turns this into a normal CI job. - Document your support floor. If you decide to drop
node10consumers, say so in the changelog and removetypesVersionsin a major release — do not leave a half-maintained map.
Frequently Asked Questions
Do I still need typesVersions if all my consumers use bundler resolution?
No. Modern resolvers ignore it when exports supplies types. Keep it only while you support consumers on moduleResolution: node10, which is still the effective default for many module: commonjs projects.
Why does the map key omit the leading ./ when exports keys include it?
They were designed separately. typesVersions keys are module paths relative to the package name (your-lib/utils becomes utils), while exports keys are subpaths that must start with ./.
Can typesVersions break consumers on newer resolvers?
Only if exports does not provide a types condition for an entry. In that case the newer resolver falls back to typesVersions, so a wrong mapping there would surface for everyone. Giving each exports entry its own types condition avoids that fallback.
Related
- TypeScript Declaration Publishing covers how declarations are generated, laid out and resolved.
- Fixing Types Not Found Under node16 Module Resolution handles the opposite failure, where modern resolvers miss your types.
- Fixing ERR_PACKAGE_PATH_NOT_EXPORTED explains the runtime side of the same exports map.
- Checking Published Types with Are the Types Wrong automates the resolver matrix in CI.