Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

Two resolvers, two routes to the same declaration file The node16 and bundler resolvers read the exports types condition, while node10 ignores exports and needs typesVersions to redirect the subpath. import 'your-lib/utils' consumer source node16 / bundler reads exports → types condition node10 legacy ignores exports; checks typesVersions dist/utils.d.ts one declaration file for both
typesVersions gives the legacy resolver a redirect that lands on the same .d.ts file the exports map already points to.

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:

  1. Add a top-level types field pointing at the root declaration. node10 reads it for bare import 'your-lib' lookups.
  2. Add typesVersions with a "*" version range. The outer key is a semver range matched against the consumer's TypeScript version; "*" means every version.
  3. Map each subpath without the leading ./. Keys in typesVersions are relative to the package root but written as utils, not ./utils — the most common mistake in hand-written maps.
  4. 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.
  5. Rebuild and pack, then check both resolvers against the tarball rather than the workspace.
Which consumers need typesVersions A matrix of TypeScript moduleResolution settings against whether they read exports, typesVersions and the top-level types field. node10 (legacy) node16 / nodenext bundler Reads exports no yes yes Reads typesVersions yes only without exports types only without exports types Reads top-level types yes (root only) fallback fallback Needs the shim yes no no
Only node10 consumers depend on typesVersions; the modern resolvers ignore it once exports supplies types.

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.

Verification loop for a typesVersions change Pack the package, install it into a fixture, compile under node10 and nodenext, and trace resolution when a check fails. npm pack tarball = what consumers install catches files missing from the files allowlist tsc under node10 exercises typesVersions tsc under nodenext exercises exports types attw --pack . full resolver matrix in one table add this to the release job
Always verify against the packed tarball, because the workspace layout can hide a wrong path.

Prevention and CI/CD guardrails

  • Generate the map instead of hand-writing it. A 20-line build script that reads exports and writes matching typesVersions entries removes the drift between the two fields.
  • Run Are the Types Wrong on every release, and fail on any node10 problem you have promised to support.
  • Keep the fixture consumers in the repo. A test/types-consumers/ directory with node10, node16 and bundler projects turns this into a normal CI job.
  • Document your support floor. If you decide to drop node10 consumers, say so in the changelog and remove typesVersions in 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

Understanding package.json Fields