How Node.js modules work

When you require a module, Node will search some paths in a certain order. Which paths? See module.paths in your Node REPL.

> pwd
/Users/denhox/Projects/Sandbox/test-node
> node
Welcome to Node.js v18.17.0.
Type ".help" for more information.
> module.paths
[
  '/Users/denhox/Projects/Sandbox/test-node/repl/node_modules',
  '/Users/denhox/Projects/Sandbox/test-node/node_modules',
  '/Users/denhox/Projects/Sandbox/node_modules',
  '/Users/denhox/Projects/node_modules',
  '/Users/denhox/node_modules',
  '/Users/node_modules',
  '/node_modules',
  '/Users/denhox/.node_modules',
  '/Users/denhox/.node_libraries'
]
šŸ¤”
Side note: the first search path (repl) is particularly interesting. I assume it’s there to conveniently override a module when using the Node REPL.

But how do modules (packages) in node_modules work? We see node_modules contains folders but how does Node’s require work with them?

To be loaded by Node’s require, a module must be:

  1. A JS file, or
  2. A folder with an index.js file, or
  3. A folder with a package.json file that has a main field

Related: https://docs.npmjs.com/about-packages-and-modules#about-modules

Here’s a sample project structure that demonstrates these three.

my-app/
ā”œā”€ index.js
ā”œā”€ package.json
ā”œā”€ node_modules/
ā”‚  ā”œā”€ a.js
ā”‚  ā”œā”€ b/
ā”‚  ā”‚  ā”œā”€ index.js
ā”‚  ā”œā”€ c/
ā”‚  ā”‚  ā”œā”€ package.json
ā”‚  ā”‚  ā”œā”€ foobar.js

Requiring a JS file

When you require a module by its name only (no relative or absolute directory), it’ll work just fine if it’s a single JS file.

// node_modules/a.js
module.exports = {
  sayHello: () => console.log("Hello from A")
}

// index.js
const { sayHello } = require('a')

sayHello(); 

// Output:
// Hello from A

Requiring a folder with an index.js file

Same thing works even if the module is a folder with an index.js file. You don’t need a package.json in this case, because Node already defaults to index.js

// node_modules/b/index.js
module.exports = {
  sayHello: () => console.log("Hello from B")
}

// index.js
const { sayHello } = require('b')

sayHello(); 

// Output:
// Hello from B

Requiring a folder with a package.json file

This is the most common way that modules are published and imported in Node. Packages in node_modules are in the form of a directory with a package.json file that defines the file name of the package’s entry point:

{
  "name": "c",
  "version": "1.0.0",
  "main": "foobar.js"
}

So when you import package ā€œcā€ via require("c"), Node will find ./node_modules/c, reads the main field, and resolves foobar.js.

// node_modules/c/foobar.js
module.exports = {
  sayHello: () => console.log("Hello from C")
}

// index.js
const { sayHello } = require('c')

sayHello(); 

// Output:
// Hello from C

What happens if package ā€œcā€ has no package.json?

// node_modules/c/foobar.js
module.exports = {
  sayHello: () => console.log("Hello from C")
}

// index.js
const { sayHello } = require('c')

// Output:
/* 
node:internal/modules/cjs/loader:1080
  throw err;
  ^

Error: Cannot find module 'c'
Require stack:
- /Users/denhox/Projects/Sandbox/test-node/index.js
    at Module._resolveFilename (node:internal/modules/cjs/loader:1077:15)
    at Module._load (node:internal/modules/cjs/loader:922:27)
    at Module.require (node:internal/modules/cjs/loader:1143:19)
    at require (node:internal/modules/cjs/helpers:110:18)
    at Object.<anonymous> (/Users/denhox/Projects/Sandbox/test-node/index.js:3:17)
    at Module._compile (node:internal/modules/cjs/loader:1256:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
    at Module.load (node:internal/modules/cjs/loader:1119:32)
    at Module._load (node:internal/modules/cjs/loader:960:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) {
  code: 'MODULE_NOT_FOUND',
  requireStack: [ '/Users/denhox/Projects/Sandbox/test-node/index.js' ]
}
*/

OK cool. Lets try something else: if there’s also an index.js file, will Node ignore it and go straight for the main file?

// node_modules/c/foobar.js
module.exports = {
  sayHello: () => console.log("Hello from C")
}

// node_modules/c/index.js
module.exports = {
  sayHello: () => console.log("Hello from C, but the index.js file")
}

// index.js
const { sayHello } = require('c')

// Output: 
// Hello from C

Good, it ignores it. That makes sense.

What happens if we remove foobar.js, rendering the main field invalid?

// node_modules/c/foobar.js
module.exports = {
  sayHello: () => console.log("Hello from C")
}

// node_modules/c/index.js
module.exports = {
  sayHello: () => console.log("Hello from C, but the index.js file")
}

// index.js
const { sayHello } = require('c')

// Output: 
// Hello from C, but the index.js file
// (node:38885) [DEP0128] DeprecationWarning: Invalid 'main' field in '/Users/denhox/Projects/Sandbox/test-node/node_modules/c/package.json' of 'bla.js'. Please either fix that or report it to the module author

Node definitely lets us know, and it falls back to the index.js file!


See Also