Fixing 'Cannot Find Module' Type Declaration Errors
A package installs cleanly, the import runs at runtime, and yet TypeScript paints the import statement red. This is a type-resolution failure, not a runtime failure — the compiler cannot find a .d.ts for the module. This page covers the two exact errors you will see, why each happens, and the minimal patch that resolves them.
Exact Symptoms
TypeScript reports one of two diagnostics, depending on whether the module is completely unknown or just untyped:
Cannot find module 'x' or its corresponding type declarations. ts(2307)
Could not find a declaration file for module 'x'. '/path/to/node_modules/x/dist/index.js'
implicitly has an 'any' type.
Try `npm i --save-dev @types/x` if it exists or add a new declaration (.d.ts)
file containing `declare module 'x';` ts(7016)
The distinction matters. ts(2307) means resolution found nothing usable at all — no JS entry and no types under the active resolution mode. ts(7016) means resolution found the JavaScript but no matching declarations, so the module is implicitly any.
Root Cause Analysis
Type resolution runs the same moduleResolution algorithm as runtime resolution, but it looks for declarations instead of executable files. It breaks for one of these reasons, which trace back to how a package wires up TypeScript Declaration Publishing:
- Missing
typesentirely. The package ships no.d.ts, no top-leveltypesfield, and notypescondition inexports. There are no community@types/xeither. Result:ts(7016). - Wrong
exportstypescondition order. The package ships declarations, but thetypeskey sits afterdefault/import/requireinsideexports. Undernode16/bundlerthe resolver matches the JS first and never reachestypes, producingts(2307)orts(7016). moduleResolutionmismatch. YourtsconfigusesmoduleResolution: "node"(legacy), which ignoresexports. A modern package that exposes types only throughexportsconditions becomes invisible, yieldingts(2307).- Missing
@typespackage. An untyped library has its declarations in a separate@types/xpackage that is not installed. - A
.d.mts/.d.ctsmismatch. The package ships a single.d.tsbut is consumed undernode16, where the ESM or CJS condition expected a format-specific declaration. This overlaps with Generating Dual CJS/ESM Type Definitions.
Resolution Steps
Work through these in order. The first that applies is usually the fix.
-
Confirm what the package actually ships. Inspect the installed package's manifest and look for a
typesfield andtypesconditions inexports:cat node_modules/x/package.json | grep -E '"(types|typings|exports)"' -A3 ls node_modules/x/dist/*.d.* 2>/dev/nullIf there are no
.d.ts/.d.mts/.d.ctsfiles anywhere, the package is untyped — go to step 2. If declarations exist, go to step 3. -
Install community types, or declare the module yourself. Many untyped libraries have a
@types/xpackage:npm install --save-dev @types/xIf none exists, add a local ambient declaration so the import is at least typed as
anyintentionally:// types/x.d.ts (ensure this dir is in tsconfig "include" or typeRoots) declare module 'x'; -
Fix
exportsordering (if you own the package). Thetypescondition must precede the JS conditions. This is the highest-frequency cause when declarations exist but are not found:{ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } }For dual packages, nest
typesfirst inside each format, as detailed in Generating Dual CJS/ESM Type Definitions. -
Align
moduleResolutionin yourtsconfig.json. If the package only exposes types throughexports, a legacynoderesolver cannot see them. Switch to a resolver that readsexports:{ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext" } }For bundler-driven apps use
"moduleResolution": "bundler"with"module": "ESNext". Both readexportstypesconditions; legacy"node"does not. -
Restart the TS language server. Editors cache resolution. After installing types or editing
tsconfig, restart the TypeScript server so the editor re-resolves.
Worked example: a package that runs but is untyped
Suppose import { parse } from 'fast-thing' runs at runtime but TypeScript reports ts(7016). Inspecting node_modules/fast-thing/package.json shows an exports map with import and require conditions but no types key anywhere. The package author shipped JS only. Because the JS resolves, you get ts(7016) (untyped) rather than ts(2307) (nothing found). There is no @types/fast-thing on the registry, so the immediate unblock is a local ambient declaration:
// types/fast-thing.d.ts
declare module 'fast-thing' {
export function parse(input: string): unknown;
}
This is your declaration, not the author's, so keep it minimal and accurate to the surface you actually call. File an upstream request for real types so you can delete the shim later. The contrast is instructive: when the JS also fails to resolve — for example a node resolver against an exports-only package — the same missing import surfaces as ts(2307) instead, and the fix is step 4, not a shim.
Validation
Verify the fix from the command line, independent of the editor's cache:
# Compiler-truth: does the project type-check with the current config?
npx tsc --noEmit -p tsconfig.json
To confirm a package you publish resolves under every consumer mode, simulate resolution against the packed tarball with an "are the types wrong" style check:
# Pack exactly what npm would publish, then probe every resolution mode
npm pack
npx --yes @arethetypeswrong/cli ./*.tgz --format table
A green result for node16 (ESM and CJS) and bundler rows means every consumer can find your types. Red rows name the exact failing mode.
Prevention & CI Guardrails
- Run
tsc --noEmitin CI on every pull request so a resolution regression fails the build, not a consumer. - Add an
@arethetypeswrong/cli --packstep before publish to catch missing or misorderedtypesconditions automatically. - Keep a one-file consumer smoke test that installs the packed tarball under
moduleResolution: "NodeNext"and type-checks an import. - Pin
typescriptand your build tool versions so resolution behavior does not drift between contributors. - When you own the package, always place
typesfirst in eachexportscondition object — make it a review checklist item.
Why the runtime resolves but the types don't
The 'cannot find module or its corresponding type declarations' error is confusing because the JavaScript often works — the module loads at runtime, but TypeScript cannot find its types. This happens because TypeScript follows the same conditional exports resolution as the runtime, and picks the declaration file under the matching condition. If a package's exports map points import and require at JavaScript but does not nest a types condition, or points at a declaration generated for the wrong format, the checker resolves the code correctly and the types not at all.
Under node16/nodenext resolution specifically, a .d.ts generated against the ESM build but served to a require consumer produces this error even when the runtime code resolves, because the checker expects a .d.cts under the require branch and does not find one. The mismatch is between how the package declares its types and how the consumer resolves them. Recognizing that runtime resolution and type resolution are separate paths — both following exports, but the types needing their own per-condition declarations — is what turns a baffling 'the import works but has no types' into a specific manifest fix: nest a types condition in each export branch pointing at a format-appropriate declaration.
Fixing it as a consumer and as an author
The fix depends on whether you own the package. As a consumer of a package with missing or broken types, the immediate workaround is a declaration stub — a .d.ts file in your project declaring the module — or, if the package ships types under a non-standard path, a paths mapping in your tsconfig pointing at them. For a @types/ package on DefinitelyTyped, installing it provides the declarations the package itself lacks. These get your build working without waiting on the package author.
As the author of the package, the durable fix is to wire the declarations correctly in your exports map so every consumer resolves them:
{
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
}
}
Nesting a types condition in each branch, pointing at the format-appropriate declaration, means both import and require consumers find correct types. Verifying with @arethetypeswrong/cli against the packed tarball confirms every resolution mode resolves a declaration, so the error cannot reach a consumer. The consumer-side workarounds unblock you today; the author-side fix removes the error for everyone, which is why reporting it upstream (or fixing it if it is your package) is the resolution that actually closes the problem.
Verifying the fix resolves for every consumer
After wiring the types conditions, confirm the fix works the way every consumer will resolve it rather than trusting a local check. @arethetypeswrong/cli packs the tarball and reports whether each module-and-resolution combination — node16 import, node16 require, bundler, legacy — finds a correct declaration, so a lingering gap under one condition shows up as a specific failing cell rather than passing silently because your own tooling happens to resolve it.
- run: pnpm build
- run: pnpm exec attw --pack .
- run: pnpm exec publint
publint complements attw by checking the exports map's structure, catching a mis-ordered or malformed condition that would misroute the types. Running both in CI means the 'cannot find declarations' error becomes a red build on the pull request that introduced it, not a consumer's editor error weeks later. The principle is that type resolution, like runtime resolution, must be validated against the packed artifact a consumer resolves, because a source-importing test never exercises the published conditions where this error lives. A package whose types are verified this way in CI simply cannot ship the missing-declaration failure that is otherwise one of the most common and confusing packaging bugs.
Frequently Asked Questions
What is the difference between ts(2307) and ts(7016)?
ts(2307) means resolution found no usable module at all under the active mode — neither JS nor types resolved. ts(7016) means the JavaScript resolved but no declarations were found, so the import is implicitly any. The first usually points at an exports/moduleResolution mismatch; the second at genuinely missing types.
Why does the import run fine at runtime but TypeScript still cannot find it?
Runtime resolution and type resolution are separate passes over the same exports map. The runtime conditions (import/require/default) can resolve perfectly while the types condition is missing, misordered, or points at a nonexistent file. Fix the types condition specifically.
Is declare module 'x'; a real fix?
It is a last resort. It silences the error by typing the module as any, sacrificing all type safety. Use it only when no real declarations or @types/x exist, and prefer writing accurate ambient types if the surface is small.
I switched to moduleResolution: "node16" and now I see more errors. Why?
node16 is stricter: it reads exports, requires explicit file extensions in relative imports, and distinguishes .d.mts from .d.cts. The new errors are real problems that legacy node resolution silently ignored. Fix them rather than reverting.
Why does the import work but TypeScript says it can't find the types?
Runtime resolution and type resolution are separate paths that both follow exports. The code resolves because the JavaScript conditions point at real files, but the types don't because the map lacks a types condition or points at a declaration for the wrong format — commonly a missing .d.cts for the require branch under node16.
How do I work around a package with missing types as a consumer?
Add a declaration stub (declare module 'pkg';) in your project, install a @types/pkg package if one exists on DefinitelyTyped, or add a paths mapping in tsconfig if the package ships types under a non-standard path. These unblock your build while you report the issue upstream.
How do I fix missing types in a package I publish?
Nest a types condition in each exports branch pointing at a format-appropriate declaration (.d.ts for import, .d.cts for require), and verify with @arethetypeswrong/cli against the packed tarball that every resolution mode resolves a declaration.
How do I confirm my types resolve for consumers under every mode?
Run @arethetypeswrong/cli --pack and publint in CI against the packed tarball. attw reports whether each resolution mode finds a correct declaration, and publint checks the exports structure, so a gap becomes a red build rather than a consumer's editor error.
Related
- TypeScript Declaration Publishing — how the
typesfield andexportsconditions are meant to be wired so resolution succeeds. - Generating Dual CJS/ESM Type Definitions — the format-specific
.d.mts/.d.ctsthat prevent masquerading errors undernode16. - Understanding package.json Fields — the manifest fields involved in declaration resolution.
- ESM and CJS Interoperability — why ESM and CJS consumers resolve modules differently.