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

Fixing Types Not Found Under node16 Module Resolution

A consumer switches their project to "moduleResolution": "node16" or "nodenext" — often because a framework or a newer TypeScript template told them to — and suddenly your package has no types. Imports that worked for years now report TS7016: Could not find a declaration file or resolve to the wrong declaration file. The runtime is fine; only the type resolution changed. This guide explains how the modern resolvers look up declarations, which package layouts break under them, and the exact exports changes that fix each case.

Exact symptoms and error messages

The most common report from a consumer:

src/index.ts:1:23 - error TS7016: Could not find a declaration file for module 'your-lib'.
'/app/node_modules/your-lib/dist/index.mjs' implicitly has an 'any' type.
  There are types at '/app/node_modules/your-lib/dist/index.d.ts', but this result could not be resolved when respecting package.json "exports". The 'your-lib' library may need to update its package.json or typings.

That message is TypeScript telling you exactly what is wrong: declaration files exist, but the exports map does not lead to them. A subpath variant:

error TS2307: Cannot find module 'your-lib/react' or its corresponding type declarations.
  There are types at '/app/node_modules/your-lib/dist/react.d.ts', but this result could not be resolved under your current 'moduleResolution' setting. Consider updating to 'node16', 'nodenext', or 'bundler'.

And the format-mismatch variant, where types resolve but describe the wrong module system:

error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'.

Root cause analysis

The legacy node10 resolver found types through the top-level types field and by looking for a .d.ts next to the resolved .js file. node16, nodenext and bundler resolution follow the exports map exactly as Node.js does, and they find types in two ways: an explicit types condition inside the matched entry, or a sibling declaration file next to the runtime target with the matching extension. When neither exists, there are no types. The general layout rules are covered in TypeScript Declaration Publishing.

How node16 resolution looks for a declaration file The resolver matches an exports entry, checks for a types condition, then checks for a sibling declaration with the matching extension, and otherwise reports no types. Does the matched entry have a types condition? Use that file must come first in the condition object yes Is there a sibling .d.ts for the target? Use the sibling index.mjs needs index.d.mts; index.cjs needs index.d.cts yes no Is there no exports field at all? Fall back to types/main legacy lookup still applies yes no TS7016: no declaration file types exist but are unreachable no
Types must be reachable through the exports map — a top-level types field is ignored when exports exists.

The failing layouts are recognisable:

  1. Top-level types only. The package has "types": "./dist/index.d.ts" and an exports map with no types conditions. Modern resolvers ignore the top-level field when exports exists.
  2. Extension mismatch. The runtime target is index.mjs but the declaration is index.d.ts. The sibling rule looks for index.d.mts for an .mjs file and index.d.cts for a .cjs file.
  3. types condition in the wrong position. Conditions are matched in object order. With "import" before "types", the resolver picks the import target first and never sees types.
  4. Subpaths without types. The root entry has a types condition, but ./react or ./server does not.

Resolution and configuration patch

Give every exports entry its own types condition, first in the object, with a declaration file whose extension matches the module format of the runtime file:

{
  "name": "your-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./react": {
      "import": {
        "types": "./dist/react.d.ts",
        "default": "./dist/react.js"
      },
      "require": {
        "types": "./dist/react.d.cts",
        "default": "./dist/react.cjs"
      }
    },
    "./package.json": "./package.json"
  },
  "main": "./dist/index.cjs",
  "types": "./dist/index.d.cts"
}

Implementation steps:

  1. Nest types under each format when you ship both ESM and CommonJS. Because this package is "type": "module", .d.ts describes ESM and .d.cts describes CommonJS. If a single index.d.ts is used for both, TypeScript assumes it describes whichever format the .d.ts extension implies for the package, and one of the two consumers gets wrong types.
  2. Generate both declaration flavours. tsup does this automatically with dts: true and format: ['esm', 'cjs']; with plain tsc, run two builds or copy and rename the declarations, as described in Generating Dual CJS/ESM Type Definitions.
  3. Put types first in every condition object.
  4. Keep top-level types and main for node10 consumers, and add typesVersions if subpaths must work there too.
  5. Verify with Are the Types Wrong before publishing.
A map that loses types versus one that keeps them Left panel shows an exports map with a top-level types field and import-first conditions; right panel shows nested types conditions per format. Types unreachable "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } -> TS7016 under node16 Types per format "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } } }
The fix moves types into each exports entry, first in each condition object, with extensions that match the runtime format.

Worked example: a UI library with a React subpath

A component library ships your-lib for framework-agnostic helpers and your-lib/react for React bindings. Its exports map has types conditions for the root but not for ./react, because the subpath was added later by a contributor who copied only the import and require lines. Consumers on bundler resolution report that useTheme is any. Running npx @arethetypeswrong/cli --pack . shows a clean row for your-lib and a "Resolution failed" or "No types" row for your-lib/react under every modern resolver. Adding the nested types conditions for ./react, regenerating react.d.cts in the build, and adding the attw check to CI fixes the report and prevents the next subpath from repeating the mistake.

When the consumer's setting is the real problem

Sometimes a consumer switches to node16 for a project that is actually bundled — a Vite or Next.js application — and then fights errors caused by Node's strict rules, such as required file extensions on relative imports. For bundled applications, "moduleResolution": "bundler" is the right setting: it follows exports and conditions like node16 but allows extensionless relative imports. When a bug report arrives, check whether the consumer's setting fits their runtime before changing your package; if your package fails under bundler too, it is your bug.

What each resolver needs from a package Compares node10, node16 or nodenext, and bundler resolution on reading exports, needing types conditions, and honouring typesVersions. node10 node16 / nodenext bundler Follows exports no yes yes Needs types in exports no yes yes Checks .d.mts / .d.cts format no yes lenient Relative imports need extensions no yes no
node16 and bundler both need types reachable through exports; only node10 relies on top-level fields.

Build tool settings that produce the right files

Most broken layouts come from build configuration rather than hand-written maps, so it is worth knowing what each common tool emits by default.

tsup with format: ['esm', 'cjs'] and dts: true emits index.js plus index.d.ts for ESM and index.cjs plus index.d.cts for CommonJS in a "type": "module" package — exactly the pair the nested map above expects. In a CommonJS package (no type field) it emits index.mjs plus index.d.mts for ESM and index.js plus index.d.ts for CommonJS; adjust the map to those names.

Plain tsc emits one declaration per source file with an extension that follows the source extension: .ts produces .d.ts, .mts produces .d.mts, .cts produces .d.cts. A dual build from a single .ts source therefore needs either two compilations with different output directories and a small package.json containing {"type": "commonjs"} in the CommonJS output folder, or a post-build step that copies and renames declarations. The folder-level package.json trick works because TypeScript and Node.js both read the nearest type field to decide the format of .js and .d.ts files.

Rollup with a declaration plugin (rollup-plugin-dts) produces a single bundled declaration per entry; name its output to match each runtime file, and run it once per format if the declarations differ.

Vite library mode does not emit declarations by itself; vite-plugin-dts does, and its rollupTypes option bundles them. Check the emitted names against your exports map after any plugin upgrade.

Whichever tool you use, the rule to verify is simple: for every runtime file referenced in exports, a declaration file with the matching extension exists in the tarball, and the types condition next to that runtime file points at it.

CLI validation and debug commands

# Full resolver matrix for the packed package
npx @arethetypeswrong/cli --pack .

# Reproduce a consumer's failure with a trace
npx tsc --noEmit --module nodenext --moduleResolution nodenext --traceResolution index.ts \
  | grep -A8 "======== Resolving module 'your-lib/react'"

# Confirm declaration files exist for every runtime target
npm pack --dry-run 2>&1 | grep -E "\.d\.(c|m)?ts$"

In the trace, look for Matched 'exports' condition 'types'. If you see Matched 'exports' condition 'import' followed by a failed sibling lookup, the types condition is missing or misplaced.

Prevention and CI/CD guardrails

  • Run Are the Types Wrong on every release, failing on any problem for resolvers you support.
  • Generate exports and declarations from one build config so new subpaths cannot be added half-configured.
  • Test with fixture consumers under node16, nodenext and bundler.
  • Lint the map with publint, which flags types conditions that are not first.

Frequently Asked Questions

Why did adding exports break types for my consumers? Because modern resolvers stop reading the top-level types field as soon as exports exists. Every entry now needs its own types condition.

Can one index.d.ts serve both ESM and CommonJS consumers? Not accurately. TypeScript infers the module format of a declaration file from its extension and the package's type field, so one file always describes one format. Ship .d.ts plus .d.cts (or .d.mts plus .d.ts in a CommonJS package).

Is node16 different from nodenext? Today they behave the same for resolution; nodenext tracks the latest Node.js behaviour and may change as Node.js does, while node16 is fixed. Libraries should test under both.

Related

TypeScript Declaration Publishing