Skip to content

Module Resolution Algorithms

intermediate15 min read

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.

Mental Model

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.

Quiz
When you require('lodash') from /app/src/utils/helper.js, where does Node.js look first?

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:

AspectCJS ResolutionESM Resolution
Extension required?No — .js is auto-appendedYes — you must include the extension (mostly)
Directory importsindex.js is auto-resolvedNot supported — must specify exact file
JSON importsrequire('./data.json') worksNeeds import assertion or --experimental-json-modules
exports fieldSupported (12.11+)Supported and preferred
Specifier typeString (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!)
Common Trap

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.

Quiz
In Node.js ESM (without a bundler), what happens with import { add } from './math'?

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
Common Trap

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:

Execution Trace
exports types condition
Check package.json exports for 'types' condition
Only with node16/nodenext/bundler resolution
package.json types field
Check top-level 'types' or 'typings' field in package.json
Fallback when exports doesn't have types
Adjacent .d.ts
Look for .d.ts file next to the .js file
math.js → math.d.ts
@types package
Check @types/[package-name] in node_modules
Community-maintained type definitions
Declaration files
Look for .d.ts files in typeRoots
Default: node_modules/@types
Quiz
With moduleResolution: 'node16', why do you write import { x } from './utils.js' when your file is utils.ts?

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';
TypeScript paths don't transform output

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 doWhat 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
Key Rules
  1. 1Node.js CJS resolution: auto-appends extensions (.js, .json, .node) and resolves directories (index.js)
  2. 2Node.js ESM resolution: requires explicit extensions and doesn't resolve directories
  3. 3Bare specifiers trigger the node_modules walk — starting from the current file's directory, walking up to root
  4. 4The exports field in package.json takes full control of what can be imported from a package
  5. 5TypeScript moduleResolution 'bundler' for web apps, 'node16' for Node.js libraries
  6. 6Use package.json imports (# prefix) for runtime path aliasing — works without a bundler