---
title: 'Module Resolution'
source: 'https://academia.sh/en/courses/nodejs/module-resolution'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:52+00:00'
license: 'CC BY-SA 4.0'
---

# Module Resolution

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

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
```

```json
// resolution/package.json
{
  "name": "measurement-collector",
  "version": "1.0.0",
  "type": "module"
}
```

```json
// resolution/node_modules/measurement-tools/package.json
{
  "name": "measurement-tools",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./index.js",
    "./units": "./units.js"
  }
}
```

```js
// resolution/node_modules/measurement-tools/index.js
export const name = 'measurement-tools';
```

```js
// resolution/node_modules/measurement-tools/units.js
export const units = { temperature: 'C', humidity: '%' };
```

```js
// resolution/node_modules/measurement-tools/internal.js
export const hidden = true;
```

```js
// resolution/summary.js
export function average(numbers) {
  return numbers.reduce((t, s) => t + s, 0) / numbers.length;
}
```

```js
// 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);
```

```sh
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:

```sh
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.

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

```sh
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:

```sh
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:

```js
// 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\//, './'));
}
```

```sh
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.
