Module Resolution & package.json
How Node Finds Your Code
LinkedIn Hook
"Why does
require('lodash')just... work? You never told Node where lodash lives."Every Node.js developer types
require('express')orimport fs from 'fs'a hundred times a day. But almost nobody can answer the follow-up: how does Node actually find that file on disk?The algorithm is deterministic, well-documented, and almost never taught. It walks
node_modulesdirectories upward like a kid asking every neighbor for sugar. It tries file extensions in a fixed order. It readspackage.jsonto figure out the entry point. It honors a modernexportsfield that can serve different files to ESM and CommonJS consumers from the same package.Understanding module resolution is the difference between guessing why a build broke and knowing which
package.jsonfield to fix. It explains whynode_modulesis huge, why peer dependencies exist, and why monorepo workspaces save gigabytes.In Lesson 2.3, I trace the full resolution algorithm and decode every important field in
package.json.Read the full lesson -> [link]
#NodeJS #JavaScript #Backend #PackageJson #InterviewPrep
What You'll Learn
- How Node resolves
require('foo'): core module -> relative path ->node_moduleswalk - The exact file extensions Node tries, and in what order (
.js,.json,.node) - How a folder becomes a module via
index.jsorpackage.jsonmain - The modern
exportsfield with conditionalimport/requireexports - The difference between
main,module, andexports - Subpath exports for clean public APIs
- Why
peerDependenciesexist and when to use them dependenciesvsdevDependenciesvsoptionalDependencies- Why
node_modulesis huge (nested deduping) - Monorepos and workspaces — hoisting and shared installs
The Address Lookup Analogy — Walking Up the Street
Imagine you live in apartment 4B and someone tells you "go borrow sugar from a neighbor named Foo." You don't have an address. What do you do?
First, you check your own apartment — maybe Foo is a roommate (a core module built into the building). If not, you knock on every door on the 4th floor (your own node_modules). If nobody named Foo lives there, you go down to the 3rd floor and knock on every door. Still nothing? Down to the 2nd floor. Then the 1st. Then the lobby. You keep walking down until you find Foo or you reach the street and give up with a "Module Not Found" error.
That is exactly how Node resolves require('foo'). It starts in the directory of the file calling require, looks inside ./node_modules/foo, and if that fails it walks up the directory tree, checking node_modules/foo at every level until it hits the filesystem root.
When Foo opens the door, you still need to know which room to enter. Foo's apartment has many rooms — you need the entry hall. That's package.json's main (or exports) field telling Node which file inside the package is the public entry point.
+---------------------------------------------------------------+
| THE NODE MODULE RESOLUTION ALGORITHM |
+---------------------------------------------------------------+
| |
| require('X') from /home/app/src/server.js |
| |
| STEP 1: Is X a core module? (fs, path, http, crypto...) |
| YES -> return the built-in. DONE. |
| NO -> continue. |
| |
| STEP 2: Does X start with './' '../' or '/'? |
| YES -> resolve as a FILE or DIRECTORY relative to caller. |
| try X, X.js, X.json, X.node |
| try X/index.js, X/index.json, X/index.node |
| try X/package.json -> read 'main' field |
| NO -> continue (it's a bare specifier). |
| |
| STEP 3: Walk up node_modules directories: |
| /home/app/src/node_modules/X |
| /home/app/node_modules/X |
| /home/node_modules/X |
| /node_modules/X |
| If found at any level -> resolve as FILE or DIRECTORY. |
| |
| STEP 4: Not found anywhere -> throw MODULE_NOT_FOUND. |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). A vertical directory tree on the left showing /home/app/src -> /home/app -> /home -> /. At each level a green folder labeled 'node_modules' with an amber arrow pointing up to the next level. On the right, three boxes: 'Core Module?' (top), 'Relative Path?' (middle), 'Walk node_modules' (bottom), each with a green arrow flowing into a final 'Resolved File' box. White monospace labels."
File Extensions and Folders as Modules
When Node finds a candidate path, it doesn't blindly require it. It tries a fixed sequence of extensions and folder shapes.
Extension Resolution Order
// caller.js
// Node tries each of the following, in order:
require('./util');
// 1. ./util (exact file, no extension)
// 2. ./util.js (JavaScript source)
// 3. ./util.json (parsed automatically into an object)
// 4. ./util.node (compiled C++ addon)
//
// If none of the above exist, Node treats './util' as a directory:
// 5. ./util/package.json -> read "main" field, resolve that file
// 6. ./util/index.js
// 7. ./util/index.json
// 8. ./util/index.node
//
// If everything fails: Error: Cannot find module './util'
Folder as a Module
// my-lib/package.json
{
"name": "my-lib",
"version": "1.0.0",
"main": "./lib/entry.js"
// When someone does require('my-lib'), Node reads this file and
// serves ./lib/entry.js as the package's entry point.
}
// my-lib/lib/entry.js
// This is what consumers actually receive.
module.exports = {
greet(name) {
return `Hello, ${name}!`;
}
};
// consumer.js
// Node walks node_modules, finds my-lib/, reads its package.json,
// resolves "main" to ./lib/entry.js, and returns that module.exports.
const lib = require('my-lib');
console.log(lib.greet('Ada')); // Hello, Ada!
If a package has no package.json main field, Node falls back to index.js inside the folder. That is why thousands of legacy npm packages still work with just an index.js at the root.
The exports Field — Modern Conditional Exports
The classic main field has one big limitation: it points to a single file, regardless of who is consuming the package. ESM consumers and CommonJS consumers get the same file. Node.js 12+ added the exports field, which lets you serve different files based on the consumer's environment.
Basic exports Field
{
"name": "my-lib",
"version": "2.0.0",
"exports": "./lib/entry.js"
}
This is equivalent to "main": "./lib/entry.js" but with one critical difference: when exports is set, Node encapsulates the package. Consumers can no longer reach into internal files like require('my-lib/lib/internal/secret.js'). Only paths explicitly listed in exports are reachable.
Conditional Exports — One Package, ESM and CJS
This is where exports shines. You can ship two versions of your library — one for ESM import, one for CommonJS require — and let Node pick the right one automatically.
{
"name": "my-lib",
"version": "2.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
}
}
}
// ESM consumer (a file with "type": "module" or .mjs)
import lib from 'my-lib';
// Node reads exports."." -> picks the "import" condition
// -> loads ./dist/esm/index.js
// CommonJS consumer (a .cjs file or "type": "commonjs")
const lib = require('my-lib');
// Node reads exports."." -> picks the "require" condition
// -> loads ./dist/cjs/index.cjs
The order of conditions inside the object matters: Node walks them top to bottom and picks the first one that matches. Always put the most specific conditions first and "default" last as a fallback.
Subpath Exports — Public Internal Paths
You can expose multiple entry points from a single package without leaking internals.
{
"name": "my-toolkit",
"version": "1.0.0",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils/index.js",
"./parser": "./dist/parser/index.js",
"./package.json": "./package.json"
}
}
// Consumers can import these specific subpaths:
const main = require('my-toolkit'); // ./dist/index.js
const utils = require('my-toolkit/utils'); // ./dist/utils/index.js
const parser = require('my-toolkit/parser'); // ./dist/parser/index.js
// But this will FAIL with ERR_PACKAGE_PATH_NOT_EXPORTED:
const secret = require('my-toolkit/dist/internal/secret.js');
// Internal files are encapsulated and unreachable from outside.
main vs module vs exports
These three fields cover three different eras of the JavaScript ecosystem:
main(oldest, universal) — points to a single CommonJS entry file. Every tool understands it. Always set it as a fallback.module(bundler convention, never standardized in Node) — points to an ESM build. Webpack, Rollup, and esbuild prefer it overmainso they can tree-shake. Node itself ignoresmodule.exports(modern, recommended) — the current source of truth. Node honors it. Bundlers honor it. It supports conditions, subpaths, and encapsulation. If bothexportsandmainare present,exportswins in Node.
{
"name": "my-lib",
"main": "./dist/cjs/index.js", // Fallback for ancient tools
"module": "./dist/esm/index.js", // Hint for old bundlers
"exports": { // Authoritative for modern Node
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
Dependencies, devDependencies, peerDependencies
Every package.json declares what packages it relies on, but not all dependencies are equal.
{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.0",
"pg": "^8.11.0"
},
"devDependencies": {
"jest": "^29.0.0",
"typescript": "^5.0.0",
"eslint": "^8.0.0"
},
"peerDependencies": {
"react": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "^2.3.0"
}
}
dependencies — Required at Runtime
These are the packages your code actually requires when it runs in production. Express, database drivers, validation libraries — anything imported by code that ships to users. npm install (without flags) installs them.
devDependencies — Required Only During Development
Tools needed to build, test, lint, or type-check your project, but not at runtime. Jest, TypeScript, ESLint, Prettier, Webpack. When somebody installs your package as a dependency of theirs, npm skips your devDependencies entirely. This keeps the dependency tree small.
peerDependencies — "Bring Your Own"
Used by libraries that need to share a single instance of another package with the host application. The classic example is a React component library: you cannot bundle React inside the library, because then the host app and the library would have two different React instances in memory, breaking hooks and context.
Instead, the library declares React as a peer dependency: "I need React, but the consumer must provide it."
{
"name": "my-react-buttons",
"version": "1.0.0",
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
}
When somebody installs my-react-buttons, npm checks whether the host app already has a compatible React. If not, it warns. The library never installs React itself — it just borrows the host's copy.
optionalDependencies — Nice to Have
Packages that may fail to install without breaking your project. The most famous example is fsevents, a macOS-only file watcher. On Linux and Windows it can't compile, so npm marks the install as "optional" and skips it instead of failing the whole installation. Your code is responsible for handling the case where the optional package is missing.
// Defensive loading of an optional dependency
let watcher;
try {
watcher = require('fsevents');
} catch (err) {
// Optional dep not available on this platform — fall back.
watcher = require('./fallback-watcher.js');
}
Why node_modules Is Huge — Nested Deduping
If you have ever opened node_modules and gasped at the size, you have met one of npm's most infamous design decisions: every dependency gets its own resolved tree, and conflicts are nested.
The Problem
Suppose your app depends on package A@1.0.0 and B@1.0.0. Both A and B depend on lodash, but A needs lodash@3.x and B needs lodash@4.x. You cannot install just one version — they are incompatible.
The Old Solution (npm v2 — pure nesting)
node_modules/
A/
node_modules/
lodash/ <-- v3
B/
node_modules/
lodash/ <-- v4
Each package gets its own private copy. When A does require('lodash'), Node walks up from A/, finds A/node_modules/lodash first, and uses that. Same for B. The walk-up algorithm makes this Just Work.
The Modern Solution (npm v3+ — flat with deduping)
node_modules/
lodash/ <-- v4 (one of them gets hoisted to the top)
A/
node_modules/
lodash/ <-- v3 (the conflicting version stays nested)
B/
(uses the hoisted lodash@4)
npm hoists whichever version it can to the top of node_modules, and only nests the conflicting copies. This dramatically reduces duplication. But because modules can still be nested, node_modules is still big — and because the hoisted layout is non-deterministic across installs, the package-lock.json file exists to freeze it.
The walk-up resolution algorithm is what makes both layouts work without changing your code. require('lodash') from inside A always finds the right version, no matter where it lives.
Monorepos and Workspaces
A monorepo is a single repository containing multiple packages. Without help, every package would have its own node_modules, duplicating massive amounts of code. Workspaces solve this.
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
]
}
my-monorepo/
package.json <-- root, declares workspaces
node_modules/ <-- shared, hoisted dependencies
react/
lodash/
...
packages/
ui/
package.json <-- "name": "@my/ui"
src/
utils/
package.json <-- "name": "@my/utils"
src/
apps/
web/
package.json <-- depends on @my/ui and @my/utils
src/
When you run npm install at the root, npm:
- Reads every package's
package.json. - Hoists shared dependencies to the root
node_modules. - Creates symlinks so
apps/web/node_modules/@my/uipoints topackages/uion disk.
The walk-up resolution algorithm handles the rest. When apps/web does require('react'), Node walks apps/web/node_modules -> apps/node_modules -> root node_modules, and finds the hoisted React. When it does require('@my/ui'), it follows the symlink straight to the local source. No publishing needed.
Tools like pnpm, Yarn workspaces, and Turborepo all build on the same idea — exploiting Node's walk-up resolution to share installs across many packages.
Common Mistakes
1. Putting runtime imports in devDependencies.
If your production code does require('lodash'), then lodash belongs in dependencies, not devDependencies. Otherwise npm install --production will skip it and your app will crash with MODULE_NOT_FOUND in production. Always ask: "does the deployed code import this?" If yes, it's a dependency.
2. Forgetting to set exports and leaking internal files.
Without exports, consumers can require('your-package/lib/internal/secret.js'). They will. And then they will complain when you refactor. Set exports to encapsulate your package and only expose the public surface.
3. Wrong order of conditional exports.
In exports, conditions are matched top to bottom and the first match wins. If you put "default" before "import", the "import" condition will never be reached. Always put specific conditions first and "default" last.
4. Bundling React inside a component library.
If your library lists React in dependencies instead of peerDependencies, the consumer ends up with two copies of React in memory — yours and theirs. Hooks break, context breaks, everything breaks. UI libraries that need React must always declare it as a peer dependency.
5. Committing node_modules to git.
It is huge, OS-specific (because of native addons), and reproducible from package-lock.json. Always add node_modules/ to .gitignore. Commit package-lock.json instead.
Interview Questions
1. "Walk me through what happens when I write require('express') in Node.js."
Node first checks whether 'express' is a core built-in module. It is not, so Node moves on. Because the specifier doesn't start with ./, ../, or /, Node treats it as a bare specifier and begins walking node_modules directories. It starts in the directory of the file calling require, looks for ./node_modules/express, and if not found walks up to the parent directory's node_modules, then its parent, and so on until it reaches the filesystem root. When it finds a directory called express, it reads express/package.json, looks at the exports field (or falls back to main), and resolves to the entry file specified there — typically something like lib/express.js. That file is loaded, executed once, its module.exports is cached, and the result is returned to the caller. Subsequent require('express') calls return the cached export without re-executing the file.
2. "What is the exports field in package.json and why is it better than main?"
exports is the modern entry-point declaration introduced in Node 12. Compared to main, it adds three things. First, encapsulation — when exports is set, consumers can only import paths explicitly listed in it; internal files become unreachable. Second, conditional resolution — you can serve different files to ESM import consumers and CommonJS require consumers from the same package, plus extra conditions like "types", "node", "browser", and "default". Third, subpath exports — you can expose multiple named entry points like my-pkg/utils and my-pkg/parser while still hiding internal files. main does none of this — it just points to a single file with no encapsulation. When both main and exports are present, Node honors exports and ignores main.
3. "Why do peerDependencies exist? Give a concrete example."
Peer dependencies exist when a library needs to share a single instance of another package with the host application, instead of bundling its own copy. The textbook example is React. If a component library like @mui/material listed React in its regular dependencies, npm could end up installing two copies of React — the host app's copy and Material UI's copy. React hooks rely on module-level state, so two copies means hooks break, context providers don't reach consumers, and useState returns from the wrong React. To prevent this, Material UI declares React as a peer dependency: "I need React, but you the consumer must install it, and we will share the same instance." The same applies to plugins for any framework — webpack plugins, ESLint plugins, Babel plugins all use peer dependencies for the same reason.
4. "Why is node_modules so big, and what does the walk-up algorithm have to do with it?"
node_modules is big because of how npm handles version conflicts between transitive dependencies. If package A needs lodash@3 and package B needs lodash@4, npm cannot install just one version — they are incompatible. So it installs both, nesting the conflicting copy inside the package that needs it: node_modules/A/node_modules/lodash. The walk-up resolution algorithm makes this work transparently — when A calls require('lodash'), Node walks up from A/, finds A/node_modules/lodash first, and uses that. Modern npm (v3+) hoists whichever version it can to the top-level node_modules to deduplicate, but conflicts still cause nesting. The combined effect is that even small projects can have hundreds of MB of node_modules because every transitive dependency is preserved and version conflicts duplicate code.
5. "What is the difference between dependencies, devDependencies, and optionalDependencies?"
dependencies are packages required at runtime — anything your shipping code imports. They are installed by npm install and also installed when somebody else lists your package as a dependency of theirs. devDependencies are tools needed only during development — test runners, linters, bundlers, type checkers. They are installed by npm install for the local project, but skipped when somebody installs your package as a dependency, keeping their tree small. optionalDependencies are packages that may fail to install without breaking the project — typically platform-specific native modules like fsevents (macOS-only). If installation fails, npm logs a warning but does not abort, and your code is responsible for gracefully handling the missing package, usually with a try/catch around the require call.
Quick Reference — Module Resolution Cheat Sheet
+---------------------------------------------------------------+
| MODULE RESOLUTION CHEAT SHEET |
+---------------------------------------------------------------+
| |
| RESOLUTION ORDER for require('X'): |
| 1. Core module? (fs, path, http, crypto...) |
| 2. Relative path? (./, ../, /) |
| 3. Walk node_modules upward to filesystem root |
| 4. Throw MODULE_NOT_FOUND |
| |
| EXTENSIONS TRIED (in order): |
| .js -> .json -> .node |
| |
| FOLDER AS MODULE: |
| 1. package.json "exports" field |
| 2. package.json "main" field |
| 3. index.js / index.json / index.node |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| PACKAGE.JSON FIELDS |
+---------------------------------------------------------------+
| |
| main -> single CJS entry (legacy, universal) |
| module -> ESM entry (bundler hint, not Node) |
| exports -> modern entry, encapsulation, conditions |
| type -> "module" (ESM) or "commonjs" (CJS) |
| |
| dependencies -> needed at runtime |
| devDependencies -> needed only for dev/build/test |
| peerDependencies -> host must provide (shared instance) |
| optionalDependencies -> install may fail without breaking |
| |
| workspaces -> monorepo packages, hoisted node_modules |
| private -> true prevents accidental npm publish |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| CONDITIONAL EXPORTS PATTERN |
+---------------------------------------------------------------+
| |
| "exports": { |
| ".": { |
| "types": "./dist/index.d.ts", |
| "import": "./dist/esm/index.js", |
| "require": "./dist/cjs/index.cjs", |
| "default": "./dist/esm/index.js" |
| }, |
| "./utils": "./dist/utils/index.js", |
| "./package.json": "./package.json" |
| } |
| |
| Order matters! First matching condition wins. |
| |
+---------------------------------------------------------------+
| Field | Purpose | Honored By |
|---|---|---|
main | Single CJS entry file | Node (legacy), all bundlers |
module | ESM entry hint | Bundlers only (not Node) |
exports | Modern entry with conditions and encapsulation | Node 12+, modern bundlers |
type | Default module system for .js files | Node |
dependencies | Runtime packages | npm install (always) |
devDependencies | Dev/build/test packages | npm install (local only) |
peerDependencies | Host-provided shared packages | npm warns if missing |
optionalDependencies | Best-effort installs | npm install (skips on failure) |
workspaces | Monorepo package list | npm, yarn, pnpm |
Prev: Lesson 2.2 -- ES Modules in Node.js Next: Lesson 3.1 -- The fs File System Module
This is Lesson 2.3 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.