skip to content

Modules, Targets & Resolution

You will learn how the compiler decides what JavaScript to emit and how it finds the files behind every import specifier. Interviewers probe here because 'cannot find module' and 'this default import is not a function' are the two most common TypeScript setup failures on real projects.

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

explore

questions

11

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

In a tsconfig.json, what does the `target` compiler option control and what does the `module` option control, and why can you not use one in place of the other?

level: juniorimportance: must knowfreq 66%

basics

~20 s

In tsconfig, target sets the language level of the emitted JavaScript syntax, deciding whether things like async/await or optional chaining get rewritten. module sets the output module format, such as CommonJS require and exports or untouched ESM. The two are independent.

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

A tsconfig sets `"target": "es5"` and `"lib": ["ES2020", "DOM"]`. A call to `[1, [2]].flat()` type-checks cleanly but throws `TypeError: ...flat is not a function` in an old browser. Explain what target and lib each do, and how you would fix this.

level: middleimportance: must knowfreq 72%

basics

~20 s

target controls emitted syntax; lib only tells the checker which built-in APIs it should believe exist. TypeScript never emits polyfills, so declaring ES2020 libs while running on an older engine type-checks calls like Array.prototype.flat that then crash at runtime.

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

What does the TypeScript compiler option `useDefineForClassFields` change about how class fields are emitted, what is its default, and what kind of working code breaks when it becomes true?

level: middleimportance: should knowfreq 34%

basics

~20 s

useDefineForClassFields switches class field emit from plain assignment to Object.defineProperty semantics, matching the ECMAScript standard. It defaults to true when target is ES2022 or higher. Fields declared without an initializer then overwrite inherited values with undefined, and they shadow base-class accessors instead of calling them.

open as a page

With `"target": "es5"` in tsconfig, TypeScript accepts `for (const x of myArray)` but rejects `for (const [k, v] of myMap)` and points you at the `downlevelIteration` option. Why the difference, and what does enabling that flag change in the emitted code?

level: middleimportance: should knowfreq 38%

basics

~20 s

At an ES5 target TypeScript compiles for...of over arrays and strings into a plain index loop, which cannot work for a Map. downlevelIteration makes the compiler emit helpers that call Symbol.iterator instead, so any iterable works — but the runtime must actually provide Symbol.iterator.

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

A TypeScript build that downlevels to ES5 emits the same helper functions — `__extends`, `__awaiter`, `__spreadArray` — at the top of many output files. What does the `importHelpers` compiler option change, what must the package declare, and when is it the wrong choice?

level: seniorimportance: should knowfreq 26%

basics

~20 s

By default the compiler inlines its downleveling helpers into every file that needs them. importHelpers makes it import them from the tslib package instead, so they exist once. tslib then becomes a real runtime dependency, which a published library must list under dependencies.

open as a page

You own the tsconfig for a package published to npm and for the application that consumes it. How do you decide `target` and `lib` for each, and what does the TypeScript compiler explicitly not do for you?

level: principalimportance: should knowfreq 38%

basics

~20 s

Pick target from the oldest runtime that must execute the emitted code, and lib from the APIs that runtime actually provides plus any polyfills you ship. The compiler downlevels syntax only: it never polyfills built-ins, and the lib you choose leaks into your published types as a requirement on consumers.

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