Fixing 'Cannot find module' Errors: TSConfig, Exports, and Extensions
"I Definitely Ran npm install!"
I installed a colleague's utility library.
npm install @team/utils
Imported it:
import { formatDate } from '@team/utils';
VSCode is happy. No red lines.
But npm run start crashes immediately.
Error: Cannot find module '@team/utils'
code: 'MODULE_NOT_FOUND'
"Wait, the file exists right inside node_modules! Why can't you find it?"
I nuked node_modules and reinstalled. Same error. It's maddening.
What Confused Me Initially? (File Exists = Readable?)
I thought "If the file exists, I can import it."
If node_modules/@team/utils/index.js is there, Node should load it, right?
But Node.js module system (especially ESM) is stricter. It doesn't just check for file existence. It checks "Did this package explicitly allow this file to be exported?"
I also missed that TypeScript's moduleResolution setting completely changes how files are looked up.
The 'Aha!' Moment (Restaurant Menu Analogy)
I understood it as a "Restaurant Menu."
- Package: The Kitchen. Full of ingredients (files).
- package.json exports: The Menu. "We only serve Pasta and Risotto."
- Developer: The Customer.
I walk into the kitchen (node_modules), open the fridge, and see Raw Meat (Internal Module).
I shout, "Give me Raw Meat (import ... from 'package/src/internal')!"
The Waiter (Node.js) refuses: "Sir, that item is not on the Menu (exports)."
Even if the file exists, if it's not on the Menu (exports), it doesn't exist to the outside world.
The Fix: 3 Common Culprits
1. package.json exports Field
Modern libraries use exports heavily.
// node_modules/@team/utils/package.json
{
"name": "@team/utils",
"exports": {
".": "./dist/index.js",
"./date": "./dist/date.js"
// "./string" is NOT here!
}
}
If I try import ... from '@team/utils/string',
I get MODULE_NOT_FOUND. Even if the file exists.
Ask the maintainer to expose it, or stick to the public entry point (.).
2. TypeScript Config (moduleResolution)
Check tsconfig.json.
{
"compilerOptions": {
"moduleResolution": "Node", // Classic
// "moduleResolution": "Bundler" // Modern (Vite, Next.js)
}
}
If set to Node (Classic), it might misinterpret exports.
For modern frameworks, switch to Bundler.
3. Missing Extensions (.js)
In Node.js ESM ("type": "module"), File Extensions are Mandatory.
// Worked in CommonJS
import { add } from './math';
// Error in ESM!
// Error: Cannot find module '.../math'
Must be explicit:
import { add } from './math.js';
VSCode might not complain (TS figures it out), but Node.js runtime will crash. (Remember the Map vs Taxi Driver?)
Deep Dive: Phantom Dependencies in Monorepos
Using pnpm or Yarn Berry creates stricter boundaries.
Package A uses lodash.
Package B imports A.
Package B also imports lodash (but forgot to add it to its own package.json).
In npm/yarn classic, this worked by accident (Hoisting). pnpm blocks this. So you get "Works locally (npm), Fails in CI (pnpm)" errors.
Solution: "Explicitly declare what you use."
Don't rely on transitive dependencies. Add lodash to B/package.json.
Application: Catch in CI
Use depcheck to solve "It works on my machine."
npx depcheck
It finds:
- Missing deps: Used in code, missing in package.json.
- Unused deps: In package.json, not used in code.
7. Deep Dive: "But It Works on My Machine!" (tsconfig paths trap)
We often use aliases like @/components.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
VSCode and tsc understand this perfectly.
But tsc compiles files while keeping the alias (@/...) intact.
Node.js has NO IDEA what @/ means. So runtime crashes.
"Cannot find module '@/components/Button'..."
Solutions
- tsc-alias: A tool that rewrites aliases to relative paths (
../../src/) after compilation. - ts-node / tsx: Run TS directly in dev.
- Subpath Imports: Node.js native feature in
package.jsonimportsfield (Modern & Recommended).
// package.json
{
"imports": {
"#src/*": "./src/*"
}
}
8. Case Study: Monorepo Hell (Symlinks & Real Paths)
Recent incident in a Monorepo.
Wanted to use packages/ui inside apps/web.
The Situation
VSCode auto-imported it as:
import { Button } from '../../packages/ui/src/Button';
Works locally. Explodes in Docker.
Because Docker context only copied apps/web, so the upper directory ../../packages didn't exist in the container.
The Fix: Use Workspace Package Names
NEVER cross package boundaries with relative paths.
Use the package name defined in package.json (@myorg/ui).
// ✅ Good
import { Button } from '@myorg/ui';
// ❌ Bad (Local Only)
import { Button } from '../../packages/ui';
We enabled eslint-plugin-import's no-relative-packages rule to ban this forever.
We enabled eslint-plugin-import's no-relative-packages rule to ban this forever.
9. Architecture: The Barrel File (index.ts) Performance Tax
We love Barrel Files (index.ts) for clean imports:
import { A, B, C } from './utils';
But at scale, this causes Circular Dependencies and Tree Shaking failures.
When Node.js imports a Barrel, it loads everything exported by it.
If you only need A, but the Barrel loads Z, and Z imports A back...
"ReferenceError: Cannot access 'A' before initialization".
Advice: Stop using Barrels for internal modules. Direct imports are faster and safer. Configure VSCode to prefer direct imports.
10. Deep Dive: The types Condition in Exports
In package.json exports, order matters.
For TypeScript 4.7+, the types condition MUST come first.
"exports": {
".": {
"types": "./dist/index.d.ts", // 👈 MUST BE FIRST!
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
If you put import before types, TS might resolve the JS file immediately and ignore the type definition, leading to Could not find a declaration file.
11. FAQ
Q: I ran npm install but still can't find module.
A: Restart VSCode (Reload Window). TS Server caches are sticky. If that fails, rm -rf node_modules and npm ci (Clean Install).
Q: Do I always need @types/package-name?
A: Only if the package is written in JS and doesn't ship with d.ts files. If it's pure TS or ships types, you don't need it.
Q: VSCode complains when I add .js extension.
A: Check tsconfig.json. Ensure "moduleResolution": "NodeNext" or "Bundler". Adding extensions is mandatory in ESM, but older TS configs might flag it as an error.
One-Line Summary
"File exists" != "Module found". Check the exports field (The Menu), add .js extensions for ESM, and declare all dependencies explicitly for pnpm.