skip to content

A TypeScript package ships `.d.ts` files and sets the `types` field in package.json, but after it adds an `exports` map, consumers on `"moduleResolution": "node16"` report that its types are gone. Why, and how do you fix it?

level: seniorimportance: should knowfreq 44%

answer

  1. exports is an encapsulation boundary
  2. the legacy field stops being read
  3. conditions are matched in order
  4. one condition per module format
  5. types first, default last

basics

~20 s

Under node16 resolution, an exports map takes over entry-point resolution and the top-level types field is no longer consulted. Add a types condition inside exports, listed first in each condition object, or place a matching .d.ts beside every resolved JavaScript file.

solid answer

~50 s

With `"moduleResolution": "node16"` (or `nodenext`, or `bundler`), TypeScript mirrors Node: once `exports` exists it becomes the **only** way in, and the legacy top-level `main` and `types` fields are ignored. So the declarations are still in the tarball, but nothing points the resolver at them. TypeScript then falls back to looking for a declaration file sitting next to the JavaScript that `exports` resolved to — `dist/index.js` → `dist/index.d.ts` — which is why some packages accidentally still work. The fix is to declare a `types` condition inside each entry of the map, and to put it **first** in the condition object, because conditions are matched in order and a `default` or `import` ahead of it will win. For a dual-format package one `types` entry is not enough: a declaration's own module format follows its extension, so the CommonJS branch needs a `.d.cts` and the ESM branch a `.d.mts`. Verify against the packed tarball, with `--traceResolution` if it is unclear which file is being picked.

code

json · 13 lines
json
{
  "name": "@acme/client",
  "version": "2.0.0",
  "main": "./dist/index.cjs",
  "types": "./dist/index.d.cts",
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    },
    "./package.json": "./package.json"
  }
}

go deeper

for a junior

Know that package.json is what tells a consumer's compiler where the declarations are, through the types field or a types condition inside exports.

for a middle

Explain that an exports map supersedes main and types under node16 resolution, that conditions match in order so types must come first, and that a declaration next to the resolved JavaScript is the fallback.

for a senior

Diagnose from the consumer side: reproduce against a packed tarball under the consumer's resolution mode, use traceResolution, and know that dual-format packages need separate .d.cts and .d.mts declarations.

for a principal

Own the compatibility surface — which resolution modes and module formats you support, whether shipping dual formats is worth its maintenance cost, and a release gate that type-checks the packed artifact in every supported configuration.

## The two eras of type resolution **Legacy (`"moduleResolution": "node"`).** The resolver reads `main` for JavaScript and the top-level `types` (or its old alias `typings`) for declarations. One entry point, two fields, no conditions. This is what almost every tutorial still shows. **Modern (`node16`, `nodenext`, `bundler`).** TypeScript follows the `exports` field the way Node does. `exports` is an **encapsulation boundary**: when it is present, only the paths it lists are reachable, and the legacy `main`/`types` fields are not consulted for those resolutions. Adding `exports` to a package that previously relied on `types` therefore removes the pointer to the declarations without touching a single `.d.ts`. ```json { "name": "@acme/client", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } } } ``` ## Why it sometimes still works, confusingly After resolving a subpath to a JavaScript file through `exports`, TypeScript will look for a declaration file **adjacent to it** with the matching name — `./dist/index.js` implies `./dist/index.d.ts`. Packages whose layout happens to satisfy that keep working without a `types` condition, which is exactly why the failure looks random from the outside: it depends on the package's output layout, not on anything the consumer did. Relying on it is fragile; declare the condition. ## Order matters, and it is a real bug source Condition objects are matched **top to bottom**, first match wins. `"default"` matches everything. So this map never delivers types: ```json { ".": { "default": "./dist/index.js", "types": "./dist/index.d.ts" } } ``` The rule is simple: `types` goes first in every condition object it appears in, and `default` goes last. The same applies inside nested condition objects — an `import` branch that itself has conditions needs `types` first within that branch too. ## Dual-format packages need more than one declaration Under `node16`, a file's module format is decided by its extension and the nearest `package.json` `"type"` — and that applies to **declaration files too**. A single `index.d.ts` in a `"type": "module"` package describes an ES module. Hand that to a consumer resolving through the `require` branch and the types describe the wrong module shape: the checker may reject `const x = require(...)` destructuring that works fine, or bless a default import that does not exist at runtime. The correct shape gives each format its own declaration: ```json { "exports": { ".": { "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } } } } ``` Those `.d.mts` and `.d.cts` files are not cosmetic renames — the extension is what tells the checker which module system the declaration describes. ## Subpaths and the deep-import trap Before `exports`, consumers could deep-import anything: `pkg/dist/internal/util`. Adding `exports` blocks every path you did not list, and the resulting error is about resolution, not about types, so it reads as a missing module. Every public subpath needs its own entry with its own `types` condition; a wildcard entry such as `"./features/*"` works but must still carry the condition. ## Diagnosing it 1. Reproduce as a **consumer**: pack the tarball, install it in a scratch project, and set the same `moduleResolution` your users have — this class of bug is invisible in the publishing repo. 2. Run the consumer's `tsc` with `--traceResolution` and read where the resolver went and what it rejected. 3. Check every branch of the map, not only the one your own tests exercise; `import` working while `require` is untyped is the most common half-broken state. ## Keep the legacy field anyway Dropping the top-level `types` field is a mistake even after adding conditions: consumers still on `"moduleResolution": "node"` read only that field. Keeping both costs two lines and covers both eras. The same reasoning applies to `main`.

  • Why does a dual-format package need both a `.d.cts` and a `.d.mts`?
    Because under `node16` a declaration file's own module format comes from its extension and the nearest `package.json` `"type"`. One declaration therefore describes one module system. Give the `require` branch a `.d.cts` and the `import` branch a `.d.mts`, or consumers on one side get types describing the other side's import and export shapes.
  • Should the top-level `types` field be removed once an `exports` map has type conditions?
    No. Consumers on the legacy `"moduleResolution": "node"` mode read only `main` and `types` and ignore `exports` entirely. Keeping both is two extra lines and covers both resolution eras; removing the legacy fields silently untypes every consumer who has not migrated.
  • How would you catch this class of bug before publishing?
    Test as a consumer, not as the author. Pack the tarball, install it into scratch projects configured with each `moduleResolution` mode you support, and type-check there. Adding that to CI catches missing conditions, unlisted subpaths and format mismatches; `--traceResolution` in the consumer project shows exactly which file the resolver picked.
  • After adding `exports`, a consumer's `import 'pkg/dist/internal/util'` stops resolving. What happened?
    `exports` encapsulates the package: only the subpaths it lists are reachable, so previously working deep imports break. That is usually intentional, but any path meant to stay public needs its own entry in the map — with its own `types` condition — or a wildcard entry that covers it.

saying these in an interview costs you the question

  • Thinks the top-level types field is still read when exports exists
  • Says the order of conditions inside the map does not matter
  • Assumes one .d.ts can serve both the require and import branches
  • Believes the declarations must be missing from the tarball
  • Tests only in the publishing repo, never as a consumer

context