Module Resolution Algorithms
The Invisible Algorithm Behind Every Import
Every time you write require('lodash') or import { chunk } from 'lodash', something has to figure out which actual file on disk that maps to. That "something" is the module resolution algorithm — and it's more complex than most developers realize.
Understanding resolution is the key to fixing "Cannot find module" errors, configuring TypeScript correctly, and knowing why certain import patterns work in some environments but not others.
Think of module resolution like a GPS navigation system. You type in a destination (the import specifier), and the GPS has to figure out the exact address (the file path). For relative paths like './utils', it's easy — you're giving directions from your current location. For bare specifiers like 'lodash', the GPS has to search through a known set of directories (the node_modules chain) until it finds a match. The exports field in package.json is like a building directory — it tells the GPS which entrance to use.
The Three Types of Specifiers
Every import specifier falls into one of three categories, and each resolves differently:
// 1. Relative specifiers — start with ./ or ../
import { add } from './math.js';
import { config } from '../config.js';
// Resolved relative to the current file's directory
// 2. Bare specifiers — no path prefix
import { chunk } from 'lodash';
import { readFile } from 'node:fs/promises';
// Resolved via node_modules lookup or Node.js built-in modules
// 3. Absolute specifiers — full URL or path
import { helper } from 'file:///Users/you/project/helper.js';
import { data } from 'https://example.com/data.js';
// Used as-is (ESM supports URL specifiers)
CommonJS Resolution Algorithm
When you call require('something'), Node.js follows this algorithm:
The node_modules walk — the clever part
For bare specifiers, Node.js walks up the directory tree looking for node_modules:
Given: /Users/you/project/src/app.js requires 'lodash'
Node.js checks:
/Users/you/project/src/node_modules/lodash
/Users/you/project/node_modules/lodash ← typically found here
/Users/you/node_modules/lodash
/Users/node_modules/lodash
/node_modules/lodash
It stops at the first match. This is why npm install puts packages in the nearest node_modules directory — and why nested node_modules directories can have different versions of the same package.
What happens inside a found package
Once Node.js finds node_modules/lodash, it resolves the entry point:
// Step 1: Check package.json "exports" field (Node.js 12.11+)
// If exports exists, it takes full control of resolution
// Step 2: If no exports, check package.json "main" field
// "main": "./dist/lodash.js" → load that file
// Step 3: If no main, look for index.js
// node_modules/lodash/index.js
The exports field, when present, is the final word. It overrides main and prevents access to files not listed in exports:
{
"name": "lodash-es",
"exports": {
".": "./lodash.js",
"./chunk": "./chunk.js",
"./map": "./map.js"
}
}
import chunk from 'lodash-es/chunk'; // OK → ./chunk.js
import map from 'lodash-es/map'; // OK → ./map.js
import internal from 'lodash-es/internal'; // ERR_PACKAGE_PATH_NOT_EXPORTED
ESM Resolution Algorithm
ES module resolution in Node.js is similar but stricter:
| Aspect | CJS Resolution | ESM Resolution |
|---|---|---|
| Extension required? | No — .js is auto-appended | Yes — you must include the extension (mostly) |
| Directory imports | index.js is auto-resolved | Not supported — must specify exact file |
| JSON imports | require('./data.json') works | Needs import assertion or --experimental-json-modules |
| exports field | Supported (12.11+) | Supported and preferred |
| Specifier type | String (can be computed) | Static string literal only (for static import) |
The biggest surprise for developers migrating from CJS to ESM: you must include file extensions:
// CommonJS — extension optional
const math = require('./math'); // Resolves to ./math.js
// ESM — extension required (in Node.js)
import { add } from './math.js'; // Must include .js
import { add } from './math'; // Error in Node.js (works in bundlers!)
Bundlers like Vite, webpack, and Rollup resolve imports without extensions — they have their own resolution logic that mimics Node.js CJS behavior. So import { add } from './math' works fine in a bundled app. But if you're writing code that runs directly in Node.js ESM (like a CLI tool or server), you must include extensions. This difference between "works in my bundler" and "works in Node.js" trips up many developers.
TypeScript Module Resolution
TypeScript has its own module resolution layer that sits on top of Node.js resolution. The moduleResolution setting in tsconfig.json controls how TypeScript finds type definitions:
The modes that matter
{
"compilerOptions": {
"moduleResolution": "bundler" // or "node16" or "nodenext"
}
}
"node16" / "nodenext" — matches Node.js behavior exactly. Requires file extensions in imports. Understands package.json exports field with "types" condition. Use this for code that runs directly in Node.js (CLI tools, servers, libraries).
"bundler" — matches what bundlers do. No extension required. Understands exports field. Use this for code processed by Vite, webpack, or other bundlers (most web apps).
// With moduleResolution: "node16"
import { add } from './math.js'; // Must include .js (even though source is .ts!)
// With moduleResolution: "bundler"
import { add } from './math'; // Extensions optional
With moduleResolution: "node16", TypeScript requires you to write .js extensions even when your source files are .ts. This seems backwards, but it makes sense: TypeScript compiles .ts to .js, so the import specifier should match the output file name. The TypeScript compiler resolves ./math.js to ./math.ts during compilation, but the emitted JavaScript keeps ./math.js — which is what Node.js will actually resolve at runtime.
How TypeScript finds types
TypeScript looks for type definitions in this order:
Path Mapping — tsconfig paths and package.json imports
TypeScript path aliases
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/lib/utils/*"]
}
}
}
// Instead of relative path hell:
import { Button } from '../../../components/ui/Button';
// You write:
import { Button } from '@/components/ui/Button';
paths in tsconfig.json is only for type resolution — TypeScript does NOT rewrite the import paths in the compiled JavaScript output. You need your bundler (Vite, webpack) or a tool like tsc-alias to resolve these paths at build time. If you're running Node.js directly, use package.json imports instead.
package.json imports (Node.js native)
Node.js has its own path mapping feature — the imports field in package.json. It works at runtime without any extra tools:
{
"imports": {
"#utils/*": "./src/lib/utils/*.js",
"#components/*": "./src/components/*.js",
"#config": "./src/config.js"
}
}
// Works in both CJS and ESM at runtime — no bundler needed
import { formatDate } from '#utils/date';
import { Button } from '#components/Button';
import config from '#config';
The # prefix is required — it distinguishes package-internal imports from npm package specifiers. This is the recommended way to do path aliasing for packages and Node.js applications.
Resolution in Practice — Debugging
When you hit a "Cannot find module" error, here's how to debug:
// CommonJS — see the exact resolution
console.log(require.resolve('lodash'));
// /Users/you/project/node_modules/lodash/lodash.js
console.log(require.resolve('./utils'));
// /Users/you/project/utils.js
// ESM — use import.meta.resolve (Node.js 20.6+)
const resolved = import.meta.resolve('lodash');
// file:///Users/you/project/node_modules/lodash-es/lodash.js
Common resolution failures and their fixes:
// ERR_MODULE_NOT_FOUND — missing extension in ESM
import { x } from './utils'; // Fails in Node.js ESM
import { x } from './utils.js'; // Fix: add the extension
// ERR_PACKAGE_PATH_NOT_EXPORTED — trying to import an internal file
import { x } from 'pkg/internal'; // Blocked by exports field
// Fix: use an exported path, or ask the maintainer to add it
// MODULE_NOT_FOUND — package not installed or wrong node_modules
require('nonexistent'); // Fails
// Fix: npm install nonexistent, or check your working directory
| What developers do | What they should do |
|---|---|
| Omitting file extensions in Node.js ESM imports Node.js ESM resolution is strict — it doesn't auto-append extensions like CJS does. Bundlers do this for you, which masks the issue during development. | Always include .js extension for relative imports in Node.js ESM |
| Using TypeScript paths without a bundler or alias resolver TypeScript paths only affect type checking — the compiled output keeps the original path strings. You need a bundler to resolve them at build time. | Use package.json imports field (with # prefix) for runtime path aliasing |
| Assuming all resolution works the same across CJS, ESM, TypeScript, and bundlers Each environment has subtly different rules. Code that works in your bundler may fail in Node.js, and code that works in CJS may fail in ESM. | Know which resolution mode your code actually runs under |
- 1Node.js CJS resolution: auto-appends extensions (.js, .json, .node) and resolves directories (index.js)
- 2Node.js ESM resolution: requires explicit extensions and doesn't resolve directories
- 3Bare specifiers trigger the node_modules walk — starting from the current file's directory, walking up to root
- 4The exports field in package.json takes full control of what can be imported from a package
- 5TypeScript moduleResolution 'bundler' for web apps, 'node16' for Node.js libraries
- 6Use package.json imports (# prefix) for runtime path aliasing — works without a bundler