skip to content

moduleResolution, Interop & Path Mapping

You will learn the resolution modes — node10, node16/nodenext, bundler — plus esModuleInterop and how baseUrl/paths rewrite specifiers for the type checker only. Interviewers ask because aliases that type-check cleanly and then explode at runtime are a rite of passage on every project.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In TypeScript, when you write `import _ from 'lodash'`, where does the compiler look for that package's type declarations, and what does the error "Could not find a declaration file for module 'lodash'" mean?

level: juniorimportance: must knowfreq 62%

answer

  1. the package gets asked first
  2. a field in its own package.json
  3. community stubs under a familiar scope
  4. no declarations means implicitly any
  5. the message only fires under one flag

basics

~20 s

The compiler looks inside the package first — package.json's types or typings field, or a types condition in its exports map — then falls back to node_modules/@types/lodash. If neither exists, the import carries no type information.

solid answer

~40 s

Finding the JavaScript and finding the *types* are two separate steps. Once the specifier `lodash` resolves to a package directory, the compiler asks that package what describes it: a `types` (or the older `typings`) field in its package.json, a `types` condition inside its `exports` map when the resolution mode reads exports, or a sibling `index.d.ts` next to the entry point. If the package ships nothing, the compiler falls back to the community-maintained stubs at `node_modules/@types/lodash` — the DefinitelyTyped naming convention, which is exactly why the error text suggests `npm i --save-dev @types/lodash`. If both lookups fail, the module has no declarations: under `noImplicitAny` that is the error you quoted, and with `noImplicitAny` off the import is silently `any`. Either way the emitted JavaScript is identical — declarations are erased and never ship.

code

json · 6 lines
json
{
  "name": "my-lib",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

go deeper

for a junior

Be ready to say that the compiler checks the package's own package.json types field first and only then falls back to node_modules/@types, and that a missing declaration makes the import any.

for a middle

Explain the two mechanisms a package can use to advertise declarations — a top-level types field versus a types condition inside exports — and note that only the newer resolution modes can see the second one.

for a senior

Show that you treat a missing or stale @types package as a correctness risk, not an annoyance: hand-written community stubs can disagree with the installed library version, and the checker cannot detect that drift.

for a principal

Own the policy angle: whether the codebase tolerates untyped dependencies at all, whether noImplicitAny is non-negotiable, and how you keep @types versions pinned in step with the libraries they describe.

## Two lookups behind one import An import specifier goes through module resolution: the compiler turns the string `'lodash'` into a location on disk, walking `node_modules` folders the way the configured `moduleResolution` mode prescribes. That step answers "which package is this?". A second, separate step answers "what describes this package's types?". Beginners tend to fuse the two and conclude that TypeScript reads a library's JavaScript to work out its shape. It does not — not for a dependency in `node_modules`. It reads *declaration files* (`.d.ts`), which are type-only files containing signatures and no implementations. ## Where a package advertises its own declarations A package that is written in TypeScript, or that ships hand-written declarations, points at them itself. The oldest mechanism is a field in its package.json: ```json { "name": "my-lib", "main": "./dist/index.js", "types": "./dist/index.d.ts" } ``` `types` and `typings` are synonyms; `types` is the current spelling. There is also a filename convention: if `main` points at `./dist/index.js` and a `./dist/index.d.ts` sits beside it, the compiler picks it up even with no field at all. Modern packages that publish an `exports` map advertise declarations through a `types` condition inside it instead. That form is only visible to the resolution modes that read `exports` at all — `node16`, `nodenext` and `bundler`. The legacy `node10` mode ignores `exports` entirely, which is a common reason a perfectly well-typed dependency looks untyped. A package that ships its own declarations needs nothing installed alongside it. Most libraries written this decade are in this category, and installing an `@types/...` package for them is usually wrong. ## The @types fallback When the package itself declares nothing, the compiler looks for `node_modules/@types/<name>`. Those are the DefinitelyTyped packages: community-written declarations published under the `@types` scope for libraries whose authors did not ship any. `lodash` is the canonical example — it is plain JavaScript, so its types live in `@types/lodash`. Scoped packages get a name-mangled folder: declarations for `@scope/pkg` live in `@types/scope__pkg` (the slash becomes a double underscore). These are development-time dependencies. They belong in `devDependencies`, they are never loaded at runtime, and they can drift out of step with the library version they describe — a real source of wrong-but-compiling code. ## What the error is actually telling you > Could not find a declaration file for module 'lodash'. '.../lodash/lodash.js' implicitly has an 'any' type. Try `npm i --save-dev @types/lodash` … Read it as: *both* lookups failed, so the whole module is `any`. The error appears only because implicit `any` is disallowed — it is reported under `noImplicitAny`, which `strict` turns on. Turn `noImplicitAny` off and the diagnostic disappears, but nothing improves: every symbol from that import is still `any`, so the checker will happily accept a misspelled method and every other misuse. Silencing the message is not fixing the problem. A related but different message is `Cannot find module 'x' or its corresponding type declarations.` That one means the *specifier itself* did not resolve — the package is not installed, or the resolution mode cannot see the subpath you asked for. ## How you fix it In order of preference: check whether the library already ships types under a newer version; install the `@types` package the error names; or, if neither exists, provide a local declaration for it — a different topic, but the point is that the declaration has to come from somewhere the compiler looks. If the package *does* ship a `.d.ts` and you still get the error, suspect the resolution mode rather than the package. ## None of this reaches runtime Declaration files are erased. Adding `@types/lodash` changes what the checker knows and changes nothing about the emitted JavaScript: the same `require('lodash')` or `import` is produced either way, byte for byte. That is why `@types` packages sit in `devDependencies` and why a missing one is a developer-experience failure, never a production failure — the code that was going to run still runs, just unchecked.

  • Does adding @types/lodash change the JavaScript your build produces?
    No. Declaration files contain only signatures and are erased at compile time, so the emitted `require`/`import` of lodash is identical with or without them. That is why `@types` packages belong in `devDependencies` — they affect checking and editor tooling only, never the shipped bundle or the runtime dependency graph.
  • Why does this error appear in one project and not in another with the same dependency?
    Because it is reported under `noImplicitAny`, which `strict` enables. With that flag off, an untyped module resolves to `any` silently and compilation succeeds — the unsafety is identical, just unreported. The other common difference is the resolution mode: `node10` cannot see declarations a package advertises only through a `types` condition in its `exports` map.

saying these in an interview costs you the question

  • Says TypeScript reads the library's JavaScript to infer its types
  • Thinks every npm package needs a matching @types package
  • Believes @types packages are loaded at runtime
  • Treats disabling noImplicitAny as fixing the missing types
  • Assumes types resolution ignores the package's own package.json

context

open as a page

A tsconfig.json sets `"paths": { "@app/*": ["src/*"] }` and `tsc` compiles with no errors, but running the emitted output with `node dist/index.js` fails with "Cannot find module '@app/utils'". Why does the compiler accept a specifier the runtime rejects?

level: middleimportance: must knowfreq 68%

basics

~20 s

Because paths is a type-checker-only mapping. The compiler uses it to find declarations for the specifier, but it never rewrites import specifiers in the emitted JavaScript, so the output still says '@app/utils' and the runtime has no idea what that means.

open as a page

In tsconfig.json, what is the difference between the `esModuleInterop` and `allowSyntheticDefaultImports` compiler options?

level: middleimportance: should knowfreq 55%

basics

~20 s

allowSyntheticDefaultImports is type-checking only: it lets you write a default import from a module that declares no default export. esModuleInterop additionally changes CommonJS emit to wrap the required value, and it turns allowSyntheticDefaultImports on for you.

open as a page

A dependency imports fine at runtime, but `tsc` reports TS2307 "Cannot find module 'pkg/utils' or its corresponding type declarations" — the package exposes that subpath only through an `exports` map in its package.json, and the tsconfig sets `"moduleResolution": "node10"`. What is going on, and what do `node16`/`nodenext` and `bundler` change?

level: seniorimportance: should knowfreq 50%

basics

~20 s

node10 is the legacy resolution algorithm: it walks node_modules by directory and file name and ignores package.json exports entirely, so a subpath declared only there is invisible to the compiler. node16, nodenext and bundler read exports maps.

open as a page

After adding `"types": ["node"]` to compilerOptions in a tsconfig.json, every test file starts failing with "Cannot find name 'describe'". What does the `types` option control, and how does it differ from `typeRoots`?

level: middleimportance: nice to knowfreq 36%

basics

~20 s

The types option is an allow-list for the @types packages that are auto-included as globals. Naming any package suppresses the automatic inclusion of all the others, so the test framework's global declarations disappear. typeRoots changes which folders that automatic inclusion scans.

open as a page