Skip to content
academia.sh

Lesson 04 / 20

Module Resolution

The three specifier types, the file-extension requirement, the package directory search chain, the exports map, and querying resolution from the program.

Contents

The previous lesson established how the process talks to the outside world. This lesson’s question is narrower but its reach is wide: the name written on an import line — which file does it correspond to?

The answer splits across three rule sets. Which set the specifier falls into is clear from its first character, and that choice determines everything else.

Three Specifier Types

A built-in specifier starts with the node: prefix: node:fs, node:path, node:http. It points to modules embedded in the runtime and is never searched for on the file system.

A file specifier starts with ./, ../, or a root path, and resolves relative to the file it appears in.

A package specifier is a name matching neither form: measurement-tools, measurement-tools/units. It gets searched for in package directories.

All three can appear side by side in a single file. The layout below repeats throughout the course: a package manifest, a local module, and a package in the dependency directory.

resolution/
  package.json
  main.js
  summary.js
  node_modules/
    measurement-tools/
      package.json
      index.js
      units.js
      internal.js
// resolution/package.json
{
  "name": "measurement-collector",
  "version": "1.0.0",
  "type": "module"
}
// resolution/node_modules/measurement-tools/package.json
{
  "name": "measurement-tools",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./index.js",
    "./units": "./units.js"
  }
}
// resolution/node_modules/measurement-tools/index.js
export const name = 'measurement-tools';
// resolution/node_modules/measurement-tools/units.js
export const units = { temperature: 'C', humidity: '%' };
// resolution/node_modules/measurement-tools/internal.js
export const hidden = true;
// resolution/summary.js
export function average(numbers) {
  return numbers.reduce((t, s) => t + s, 0) / numbers.length;
}
// resolution/main.js
import { readFileSync } from 'node:fs';
import { average } from './summary.js';
import { name } from 'measurement-tools';
import { units } from 'measurement-tools/units';

console.log('built-in module :', typeof readFileSync);
console.log('file module     :', average([21.4, 19.8, 24.1]).toFixed(2));
console.log('package module  :', name);
console.log('package subpath :', units.temperature);
node resolution/main.js
built-in module : function
file module     : 21.77
package module  : measurement-tools
package subpath : C

Four files have the .js extension; the "type": "module" field in the nearest manifest is what gets them interpreted as ES modules. The dependency directory’s own manifest carries the same field — a package’s module form comes from its own manifest, not the manifest of whatever uses it.

The Extension Is Required for File Specifiers

In CommonJS, require('./summary') was enough; the runtime tried ./summary.js, ./summary.json, ./summary.node in turn, and failing those, looked at the directory file in ./summary/. This guessing costs several file-system calls per import and means different environments could pick a different file for the same name.

There is no guessing in ES modules:

cd resolution && node -e "import('./summary').catch(e => console.log(e.code))"
ERR_MODULE_NOT_FOUND

Writing the extension is not a burden, it is a contract that reduces resolution to a single step: a specifier maps to exactly one address, computable without touching the file system.

The Package Search Chain

When a package specifier is seen, the search starts from the importing file’s directory and checks the node_modules subdirectory at every step. If not found, it climbs one directory up; this continues up to the root directory.

// resolution/deep/deeper/deep.js
import { name } from 'measurement-tools';
console.log('found node_modules in an ancestor dir:', name);
node resolution/deep/deeper/deep.js
found node_modules in an ancestor dir: measurement-tools

The file is two directories deep with no dependency directory beside it; the name was found by climbing the chain upward. This rule underlies the dependency-tree layout explained in the Modules, Tooling and the Ecosystem course: different versions of the same package can coexist by sitting at different depths.

The Exports Map

A package’s exports field determines which paths can be imported from outside. The package above defines two entries: the root (.) and ./units. The third file has no place in the map:

cd resolution && node -e "import('measurement-tools/internal.js').catch(e => console.log(e.code))"
ERR_PACKAGE_PATH_NOT_EXPORTED

This differs from the file not existing: the file is right where it is, but the package does not count it as part of its public interface. The map gives the package author a way to apply the interface–implementation distinction from the Programming Fundamentals course. Without a map, every file in the package is open, and any internal reorganization can break its users.

A package can also define aliases for its own internal use. Names starting with # in the imports field resolve only from that package’s own files and stay invisible from outside. This mechanism returns in the Package Publishing lesson.

Querying Resolution

Which specifier corresponds to which file can be asked from the program itself:

// resolution/resolve.js
const paths = ['node:fs', './summary.js', 'measurement-tools', 'measurement-tools/units'];
for (const path of paths) {
  const resolved = import.meta.resolve(path);
  console.log(path.padEnd(22), resolved.replace(/^.*\/resolution\//, './'));
}
node resolution/resolve.js
node:fs                node:fs
./summary.js           ./summary.js
measurement-tools      ./node_modules/measurement-tools/index.js
measurement-tools/units ./node_modules/measurement-tools/units.js

The output paths are file addresses; the example trims the common prefix for readability, and the real values start with file://. The built-in module, though, comes back as its own specifier rather than an address, since it has no counterpart on the file system.

This query is the shortest way to verify a dependency comes from the expected file. import.meta.resolve exists only in ES modules; its CommonJS counterpart is require.resolve.

When a bridge between the two forms is needed, createRequire from node:module produces a require that works inside an ES module — the way to use a dependency not published as an ES module, used again in the Package Publishing lesson.

Modules Are Evaluated Once

A module is evaluated once per resolved address; even imported from many places, its body never runs again, and the same export object is shared. Module-level state is therefore singular for the whole process — a counter, a connection pool, a configuration object created in a module body is the same instance for everyone who imports it.

The detail: singularity depends on the resolved address, not the specifier. Two copies of the same package in two different directories give two addresses and produce two instances — the root of the problem duplicated versions cause in a dependency tree.

An import declaration can only be written at a module’s top level and unconditionally, since dependencies resolve before the body runs. When conditional loading is needed, the promise-returning import() form is used — the measurement collector can use it for output formatters that load only when requested.

Summary

  • Specifiers come in three types: node:-prefixed built-ins, file paths starting with ./ or ../, and package names outside those forms. The type determines the resolution rule.
  • File extensions are required in ES modules; extension guessing and directory-file lookup are specific to CommonJS.
  • A package name is searched for in the node_modules directory at every level, starting from the importing file’s directory and going up.
  • The exports map fixes a package’s public interface; access to a file not in the map is rejected even if the file exists.
  • A module is evaluated once per resolved address; module-level state is singular for everyone sharing that address.

Next Step

Four parts of the runtime model are complete: the host environment, the event loop, the process object, and module resolution. The next topic takes up, in order, the built-in modules built on this model. The first stop is the file system: three interfaces offer the same read operation, and the choice among them directly determines the blocking cost measured in the event-loop lesson.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close