How the compiler actually resolves your import paths
The wrong moduleResolution setting produces confusing cannot find module errors even though the code is actually correct. This article explains how TypeScript resolves import paths under node node16 and bundler why Vite and Webpack follow different rules than the plain tsc compiler and how to match the right strategy to your actual build tool.
Table of Contents
- 1. Why "cannot find module" errors happen
- 2. How Node's CommonJS resolution algorithm works (the classic node strategy)
- 3. ESM resolution and the node16/nodenext strategy
- 4. The bundler strategy (for Vite, Webpack, esbuild)
- 5. How paths and baseUrl interact with moduleResolution
- 6. Matching moduleResolution to your actual build tool
- 7. Debugging "cannot find module" step by step
- 8. Monorepo and package.json "exports" pitfalls
- 9. Module resolution strategies compared
- 10. Summary
- 11. FAQ
1. Why "cannot find module" errors happen
Few TypeScript error messages cause as much confusion as Cannot find module './utils' or its corresponding type declarations. The cause is rarely missing code, it is almost always the wrong moduleResolution setting in tsconfig.json. TypeScript strictly separates type checking from actual module resolution: the compiler simulates how an import path would be resolved at runtime, and that simulation follows a different algorithm depending on which strategy is configured.
Pick the wrong strategy and you either get red squiggles under code that actually works fine at runtime, or worse, tsc reports no error at all while the bundler or Node.js crashes when the code actually runs. Since TypeScript 5.0, moduleResolution: "bundler" exists as a third main option alongside the classic node strategy and the strict node16/nodenext strategies. Understanding which strategy mirrors which runtime behavior lets you diagnose most module resolution errors in seconds instead of hours.
2. How Node's CommonJS resolution algorithm works (the classic node strategy)
The setting moduleResolution: "node" (also called node10 in newer TypeScript versions) mirrors the classic CommonJS algorithm that Node.js's require() has always used. For a relative import like ./utils, the algorithm first checks the exact file, then the same file with the extensions .ts, .tsx, and .d.ts, then a file of the same name with .js. If no matching file exists, it checks whether ./utils is a directory containing an index file.
For non relative imports like lodash, the algorithm walks up the directory tree from the current folder step by step, searching every parent node_modules folder until it finds a match or reaches the filesystem root. This behavior is convenient because virtually any import path works without an extension, but it no longer matches the strict ESM resolver that Node.js has used since version 12 for real .mjs files and "type": "module" packages.
3. ESM resolution and the node16/nodenext strategy (package.json exports, mandatory extensions)
With moduleResolution: "node16" or "nodenext", TypeScript mirrors the actual ECMAScript module resolver of Node.js, not the old CommonJS algorithm anymore. The most important practical difference: relative imports require an explicit file extension. An import like ./utils fails, while ./utils.js works, even if the source file is actually named utils.ts. TypeScript deliberately expects the extension that will exist after compilation, not the extension of the source file.
In addition, node16/nodenext respects the exports field in a package's package.json and blocks imports of files that are not explicitly exposed there, even if the file physically exists inside the node_modules folder. The field "type": "module" in package.json also decides per package whether .js files are interpreted as CommonJS or as ESM, something TypeScript tracks for every single file in your project as well.
// tsconfig.json: "moduleResolution": "node16"
// utils.ts
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2);
}
// checkout.ts
// WRONG under node16: missing file extension
import { formatPrice } from './utils';
// error TS2835: Relative import paths need explicit file
// extensions in ECMAScript imports when '--moduleResolution' is 'node16'
// RIGHT under node16: extension refers to the compiled output, not the source file
import { formatPrice } from './utils.js';
4. The bundler strategy (designed for Vite, Webpack, esbuild)
The setting moduleResolution: "bundler", introduced in TypeScript 5.0, solves a real problem: neither the old node strategy nor node16/nodenext correctly model how modern bundlers actually resolve modules. Vite, Webpack, and esbuild do not require a file extension for relative TypeScript imports, yet they still respect the exports field of modern packages. The bundler strategy combines exactly these two properties, allowing ./utils without an extension while at the same time understanding modern package.json field conventions.
Important to understand: moduleResolution: "bundler" is meant exclusively for type checking. It tells tsc to behave like your actual build tool, but it does not itself emit JavaScript code for production use when you rely on Vite or Webpack for the real build. In that setup tsc typically runs only with noEmit: true for type checking, while the bundler handles the actual bundling.
// tsconfig.json - for a Vite or Webpack project
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}
// tsconfig.json - for a tsc-only CLI build (Node.js ESM output)
{
"compilerOptions": {
"target": "ES2022",
"module": "node16",
"moduleResolution": "node16",
"outDir": "dist",
"declaration": true
}
}
5. How paths and baseUrl interact with moduleResolution
The paths field in tsconfig.json is purely a compile time shortcut for the TypeScript compiler, not a real runtime redirect. An entry like "@app/*": ["src/*"] tells tsc where to find the types for @app/utils, but it creates no mechanism that actually resolves this alias at runtime. That is the most common source of a deceptive pattern: the editor shows no errors, tsc compiles cleanly, but Node.js or the test runner throws Cannot find module '@app/utils' when the code actually runs.
Vite and Webpack need their own, separate alias configuration, such as resolve.alias in vite.config.ts, maintained independently of tsconfig.json. For plain Node.js scripts without a bundler, you additionally need a package like tsconfig-paths or tsc-alias that rewrites the aliases into real relative paths after compilation. baseUrl additionally affects which root directory non relative imports without a matching paths entry are searched from, though node16/nodenext now largely ignores it.
6. Matching moduleResolution to your actual build tool
The rule of thumb is simple, yet it is still ignored regularly: moduleResolution should always mirror the tool that actually resolves your modules at runtime, not whichever setting merely sounds the most modern. If you build with Vite, Webpack, or esbuild, "bundler" belongs in your tsconfig.json, because those tools own the resolution and tsc is only responsible for type checking. If instead you run code directly with tsc or ts-node without an additional bundler, the setting must exactly mirror how Node.js itself resolves modules, so node16 or nodenext.
Libraries published as an npm package that are imported by both CommonJS and ESM consumers should also use node16/nodenext, because only this strategy correctly performs the exports field validation required for dual packages. A Vite alias in your vite.config.ts also has no effect on type checking unless tsconfig.json knows the same alias through paths: both configurations must be maintained in sync, otherwise editor experience and actual build behavior drift apart.
// vite.config.ts - runtime alias resolution (this is what actually runs)
import { defineConfig } from 'vite';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@app': path.resolve(__dirname, 'src'),
},
},
});
// tsconfig.json must mirror the same alias for the type checker,
// otherwise the editor and tsc will report false "cannot find module" errors:
// {
// "compilerOptions": {
// "moduleResolution": "bundler",
// "baseUrl": ".",
// "paths": { "@app/*": ["src/*"] }
// }
// }
7. Debugging "cannot find module" step by step
The first step with any cannot find module error is to read the exact error message instead of skimming it. TypeScript distinguishes between several related but different error codes: TS2307 (Cannot find module) points to a fundamental resolution problem, TS2835 points to a missing file extension under node16/nodenext, and TS7016 points to a package without type definitions. The --traceResolution flag on a direct tsc invocation prints, for every import path, exactly which candidate paths the compiler checked and at which point the search failed.
The second step is checking module and moduleResolution for consistency: since version 5, TypeScript directly rejects certain inconsistent combinations with a configuration error. The third step concerns node_modules itself: a full reinstall fixes a surprising number of cases where a stale package.json with an incorrect exports field had been cached. Only once all of that is ruled out is it worth hunting for a genuine configuration mistake in your own tsconfig.json.
$ npx tsc --noEmit
src/checkout.ts:3:29 - error TS2307: Cannot find module './utils' or its
corresponding type declarations.
3 import { formatPrice } from './utils';
~~~~~~~~~~
# Enable verbose resolution tracing to see every candidate path tsc checked
$ npx tsc --noEmit --traceResolution | grep -A 2 "Module './utils'"
# Rule out a stale node_modules / exports-field cache
$ rm -rf node_modules package-lock.json && npm install
8. Monorepo and package.json "exports" pitfalls
In monorepos using npm, pnpm, or Yarn workspaces, one package frequently imports another by its package name, such as @internal/shared-ui. As soon as that internal package defines a modern exports field, node16/nodenext blocks every import path that is not explicitly listed there, even if the target file physically exists and would otherwise be reachable through a relative path without any problem. A common mistake: a developer imports @internal/shared-ui/dist/utils directly because it used to work, but after an exports field gets introduced, that path disappears from the package's public API.
The reliable fix is to explicitly declare every deliberately public entry point in the exports field, including separate entries for types, import, and require, so that both node16/nodenext and older consumers resolve correctly. TypeScript project references with composite: true make the problem worse still when the referenced packages use different module settings, because their output formats then stop being compatible with each other.
{
"name": "@internal/shared-ui",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
}
}
// Importing a path not listed above fails under node16/nodenext
// even though the file physically exists on disk:
// import { formatPrice } from '@internal/shared-ui/dist/utils';
// error TS2307: Cannot find module '@internal/shared-ui/dist/utils'
// or its corresponding type declarations.
9. Module resolution strategies compared
The table below shows which moduleResolution setting fits which build scenario, and which choice regularly leads to cannot find module errors in practice.
| Scenario | Wrong choice | Recommended moduleResolution | Why |
|---|---|---|---|
| tsc-only CLI build (Node.js output) | classic / node | node16 / nodenext | Mirrors the real Node.js ESM resolver, including exports field and mandatory extensions |
| Vite project | node16 | bundler | Vite allows extensionless imports, node16 wrongly enforces file extensions |
| Webpack project | classic | bundler | Webpack respects exports and needs no .js extension on TS imports |
| Node.js ESM library (npm package) | node | node16 / nodenext | Only node16/nodenext correctly validates the exports field for ESM consumers |
| Node.js CommonJS library (npm package) | bundler | node / node16 | bundler ignores the CommonJS-specific rules that require() actually uses |
In practice, the biggest lever is not picking whichever setting sounds theoretically the most modern, but picking the setting that exactly matches the tool that actually resolves your modules at runtime. Getting this table wrong rarely produces an obvious crash, more often it produces silent discrepancies between editor, type checking, and production behavior that only surface with unusual import paths.
Mironsoft
Build tooling, TypeScript configuration, and frontend infrastructure for Magento and headless projects
Ready to solve your TypeScript build errors for good?
We analyze your tsconfig.json, align moduleResolution with your actual build tool, and fix cannot find module errors for good, whether you work with Vite, Webpack, or plain tsc.
tsconfig audit
Analysis of moduleResolution, paths, and exports fields for inconsistencies
Build tool migration
Switching between tsc, Vite, and Webpack without module resolution chaos
Monorepo setup
Clean exports fields and project references for workspaces
10. Summary
The moduleResolution strategies in TypeScript, node, node16/nodenext, and bundler, solve one shared underlying problem: the compiler has to mirror how import paths are actually resolved at runtime, otherwise type checking and production behavior drift apart. The classic node strategy mirrors the old CommonJS algorithm and allows extensionless imports. node16/nodenext mirrors the strict ECMAScript module resolver of Node.js, including mandatory extensions and exports field validation. bundler combines the convenience of extensionless imports with an understanding of modern exports fields, and is the right choice for practically every Vite, Webpack, or esbuild project.
The decisive mistake is rarely a lack of technical knowledge, it is a misconfiguration that does not match the actual build tool: paths without a matching bundler alias, node16 in a Vite project, or bundler in a library that is also imported by plain CommonJS consumers. Consistently coupling moduleResolution to the actual runtime environment and explicitly maintaining exports fields in your own packages eliminates most cannot find module errors for good, instead of debugging them one at a time.
Module Resolution in TypeScript - The Essentials at a Glance
node vs. node16
node mirrors the old CommonJS resolution, node16/nodenext mirrors the real ESM resolver with mandatory extensions and exports validation.
bundler strategy
Introduced in TypeScript 5.0 for Vite, Webpack, and esbuild: extensionless imports plus a modern understanding of the exports field.
paths is not a runtime alias
tsconfig paths only affects type checking. Vite, Webpack, or tsc-alias need their own, separately maintained alias configuration.
Maintain the exports field
Only explicitly listed entry points are importable under node16/nodenext, even if the file physically exists.