Fix TypeScript Module Resolution Errors in Node.js ESM Projects
Quick Answer
- Root cause: TypeScript's
moduleResolutionsetting must match your project's module system (ESM vs CJS) and runtime environment (Node.js vs bundler). Mismatches cause "Cannot find module" errors, path alias failures, and ESM/CJS confusion. - First checks: Verify your
tsconfig.jsonhas"moduleResolution": "node16"or"nodenext"for modern Node.js ESM projects, and confirmpackage.jsoncontains"type": "module"if using ESM. - Minimal fix: Set
"module": "NodeNext"and"moduleResolution": "NodeNext"intsconfig.json, ensure all import paths include file extensions (.js), and restart your TypeScript server. - Environment boundary: This guidance applies to TypeScript 4.7+ with Node.js v12+; for bundlers (Vite, webpack), use
"moduleResolution": "bundler"instead.
What Problem It Solves
TypeScript module resolution errors occur when the compiler cannot locate imported modules or their type declarations. This is especially common when:
- Migrating from CommonJS to ESM
- Using path aliases (e.g.,
@/components) - Working with monorepos containing multiple packages
- Upgrading Node.js or TypeScript versions
- Depending on ESM-only packages
The systematic approach described here provides a layered diagnostic method (5-level check) to identify and fix resolution failures, emphasizing maintenance cycles and signal recognition rather than one-time configuration.
Root Cause Analysis
TypeScript uses the moduleResolution setting to determine how it resolves module imports. The key strategies are:
| Strategy | Best For | Extension Requirements | ESM Support |
|---|---|---|---|
node16 / nodenext | Node.js v12+ | Required (.js, .mjs, .cjs) | Full |
bundler | Vite, webpack, esbuild | Optional | Partial |
node10 | Legacy Node.js | Not required | None |
classic | Pre-1.6 TypeScript | Not required | None |
The most common failure pattern: using bundler mode during development (which doesn't enforce file extensions) but running with Node.js ESM (which requires them). This creates a gap where TypeScript compiles successfully but Node.js throws runtime errors.
Minimal Working Configuration
For a modern Node.js ESM project, your tsconfig.json should include:
JSON{ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }
And your package.json must include:
JSON{ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } } }
Common Errors and Fixes
Error: "Cannot find module 'x' or its corresponding type declarations"
Solution (layered check):
- Verify the package is installed in the current workspace (
npm ls xor checkpackage.json) - Check if the package publishes type declarations (look for
typesortypingsin itspackage.json) - Import from the package root path instead of deep paths
- Ensure
moduleResolutionmatches the package format (ESM packages requirenode16/nodenext)
Error: Path alias imports compile in one place but fail elsewhere (Jest, ts-node)
Solution:
- Configure aliases in TypeScript's
pathswithintsconfig.json - Configure separate alias mappings for runtime tools:
- Jest:
moduleNameMapperinjest.config.js - ts-node: use
tsconfig-pathsmodule
- Jest:
- Consider using
bundlermode (e.g., with Vite) for unified resolution - Restart TypeScript server and development processes
Error: ESM errors after upgrading Node.js or TypeScript
Solution:
- Re-evaluate your module strategy: decide ESM or CJS as the foundation
- Update
tsconfig.jsonwith"module": "NodeNext"and"moduleResolution": "NodeNext" - Check
package.jsontypefield ("module"or"commonjs") - Ensure all import statements use correct extensions (
.mjs/.cjsor.js)
Error: Deep imports into packages fail after package update
Solution:
- Check the package's
package.jsonexportsfield to see if deep paths are exported - Switch to the package's public entry point (
import from 'some-lib'instead of'some-lib/dist/utils') - If deep imports are necessary, consider forking the package or submitting a PR to add exports
- Update the package to the latest version and review changelog for export changes
Production Notes and Security Checks
Critical limitations:
-
Path aliases (e.g.,
@/) are TypeScript-compile-time only — runtime tools (Node, Jest) require separate configuration:- Node: use
tsconfig-pathsorts-node --paths - Jest: configure
moduleNameMapper - Bundlers: usually handle automatically
- Node: use
-
bundlermode doesn't enforce file extensions — but Node.js native ESM requires them, causing runtime errors -
Monorepo complexity — different packages may use different
moduleResolutionstrategies, causing cross-package import failures -
Version sensitivity — upgrading Node.js or TypeScript may change module resolution behavior; always re-validate configuration
Security recommendations:
- Avoid deep imports (
some-lib/dist/internal) — use public entry points only - Regularly audit
tsconfig.jsonpathsandbaseUrlto prevent path leakage or unauthorized access - Use
"skipLibCheck": truein production to reduce compilation time, but verify type declarations separately
FAQ
Q: Why does my TypeScript code show "Cannot find module" in VS Code but compiles and runs fine?
A: This usually happens when VS Code uses a different TypeScript version than your project, or path aliases only work at compile time. Fix: Press Ctrl+Shift+P, select "TypeScript: Select TypeScript Version", choose "Use Workspace Version", then restart the TS server (Ctrl+Shift+P → "TypeScript: Restart TS Server"). If using path aliases, ensure paths and baseUrl are correctly configured in tsconfig.json.
Q: How do I manage module resolution across multiple packages in a monorepo?
A: Use TypeScript Project References with a shared base config. Create tsconfig.base.json with common settings (e.g., "moduleResolution": "node16"), then each sub-package's tsconfig.json extends it and sets references to dependencies. Use pnpm or yarn workspaces to ensure consistent TypeScript versions across packages. Run tsc --build regularly to build all referenced projects.
Q: My project migrated from CommonJS to ESM and now has "require is not defined" errors everywhere. How do I fix this systematically?
A: Follow this migration sequence: 1) Add "type": "module" to package.json; 2) Update tsconfig.json with "module": "NodeNext" and "moduleResolution": "NodeNext"; 3) Replace all require() with import statements, ensuring import paths include file extensions (.js); 4) For dynamic imports, use import() function or keep .cjs files; 5) Check all dependencies for ESM support (look at exports field in their package.json); 6) Update test tool configurations (Jest transforms, moduleNameMapper); 7) Run tsc --noEmit to check types, then fix runtime errors incrementally.