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.
The failing layouts are recognisable:
- Top-level
typesonly. The package has"types": "./dist/index.d.ts"and anexportsmap with notypesconditions. Modern resolvers ignore the top-level field whenexportsexists. - Extension mismatch. The runtime target is
index.mjsbut the declaration isindex.d.ts. The sibling rule looks forindex.d.mtsfor an.mjsfile andindex.d.ctsfor a.cjsfile. typescondition in the wrong position. Conditions are matched in object order. With"import"before"types", the resolver picks theimporttarget first and never seestypes.- Subpaths without types. The root entry has a
typescondition, but./reactor./serverdoes 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:
- Nest types under each format when you ship both ESM and CommonJS. Because this package is
"type": "module",.d.tsdescribes ESM and.d.ctsdescribes CommonJS. If a singleindex.d.tsis used for both, TypeScript assumes it describes whichever format the.d.tsextension implies for the package, and one of the two consumers gets wrong types. - Generate both declaration flavours. tsup does this automatically with
dts: trueandformat: ['esm', 'cjs']; with plaintsc, run two builds or copy and rename the declarations, as described in Generating Dual CJS/ESM Type Definitions. - Put
typesfirst in every condition object. - Keep top-level
typesandmainfornode10consumers, and addtypesVersionsif subpaths must work there too. - Verify with Are the Types Wrong before publishing.
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.
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
exportsand declarations from one build config so new subpaths cannot be added half-configured. - Test with fixture consumers under
node16,nodenextandbundler. - Lint the map with publint, which flags
typesconditions 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 covers declaration layouts in depth.
- Configuring typesVersions for Older TypeScript Consumers handles the legacy resolver side.
- Fixing 'Masquerading as CJS' Type Errors addresses declarations that describe the wrong module format.
- Checking Published Types with Are the Types Wrong automates this verification.