---
title: 'Package Registries and Dependencies'
source: 'https://academia.sh/en/courses/javascript-ecosystem/package-registries-and-dependencies'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# Package Registries and Dependencies

The fields of the manifest file, resolving a bare specifier by walking the directory tree, encapsulation through the exports map, dependency types, and what the lock file does.

Every specifier in the module systems topic was a relative file path. In real projects,
most imports are bare names like `format-helper`: no extension, no directory
information.

What links this name to a file is a filesystem convention and the package's own
declaration. This lesson first defines what a package is, then follows step by step how
a bare name is searched for.

## The Manifest File

A **package** is a directory that carries its own declaration. The **manifest file
(manifest)** defines the package's identity, entry points, and dependencies. This is the
language-level counterpart of the metadata concept introduced in the package manager
lesson of the Introduction to Linux course; the role of the fields is similar too.

The measurement library's manifest — `package.json` at the project root:

```json
{
  "name": "text-measurer",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": "./src/index.mjs",
    "./segmentation": "./src/segmentation.mjs"
  },
  "files": ["src"],
  "dependencies": {
    "format-helper": "^1.4.0"
  }
}
```

The decisions carried by the fields:

| Field | Decision |
|---|---|
| `name` | The package's unique identity within the registry |
| `version` | The version of this release; the second part of the identity |
| `type` | Which module system files with a `.js` extension are read as |
| `exports` | Which paths can be imported from outside |
| `files` | Which files go into the published archive |
| `dependencies` | The packages needed to run, and the accepted version ranges |

The last three fields are design decisions, not technical requirements. It is the
package's author who decides which file is visible from outside, which is published,
and which versions are accepted.

## Adding a Dependency

The measurement library depends on a small helper to print the average length with a
fixed number of digits. The helper package is a directory carrying its own manifest —
`node_modules/format-helper/package.json`:

```json
{
  "name": "format-helper",
  "version": "1.4.0",
  "type": "module",
  "main": "index.mjs"
}
```

```javascript
// file: node_modules/format-helper/index.mjs
export function decimal(value, digits = 2) {
  return value.toFixed(digits);
}
```

The report module added to the library imports this package by its bare name.
`src/index.mjs` below, along with `src/segmentation.mjs` and `src/statistics.mjs` that
it uses, was written in this topic's ES Modules lesson; the library keeps growing on
the same tree:

```javascript
// file: src/report.mjs -- src/index.mjs is the entry point that exports the measure function
import { decimal } from 'format-helper';
import { measure } from './index.mjs';

export function report(text) {
  const measurement = measure(text);
  return `${measurement.wordCount} words, average ${decimal(measurement.averageLength)} characters`;
}
```

```javascript
// file: try.mjs
import { report } from './src/report.mjs';

console.log(report('A module carries its own scope. A script runs in global scope.'));
console.log(import.meta.resolve('format-helper').split('/node_modules/')[1]);
```

```
$ node try.mjs
12 words, average 4.08 characters
format-helper/index.mjs
```

There are two separate facts here that do not require each other: the package being
**written in the manifest** and the package **being present in the directory**. A
package written in the manifest but not present in the directory produces a resolution
error:

```javascript
// file: missing-package.mjs
import { decimal } from 'nonexistent-package';

console.log(decimal(1.5));
```

```
$ node missing-package.mjs 2>&1 | grep '^Error' | sed "s|$(pwd -P)/||g"
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'nonexistent-package' imported from missing-package.mjs
```

The reverse case is sneakier: a package present in the directory but not written in the
manifest runs without a hitch locally. Resolution only looks at the filesystem, not the
manifest. Installed somewhere else, that file may not be there, and the error only
surfaces on first use. Every used dependency has to be written in the manifest too —
the assumption underlying the tree arrangements covered later.

## How a Bare Specifier Is Resolved

A bare name is not a file path; it is a name that has to be searched for. The rule fits
in one sentence: start from the directory of the file doing the importing, look for
this name inside a `node_modules` subdirectory at every level, and if not found, move
up one directory.

A script that applies the rule directly shows that the search really does proceed this
way:

```javascript
// file: src/deep/search.mjs
import { dirname, join } from 'node:path';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

// Directories searched for the bare specifier: from the current directory toward the root.
let dir = dirname(fileURLToPath(import.meta.url));
while (true) {
  const candidate = join(dir, 'node_modules', 'format-helper');
  console.log(`${existsSync(candidate) ? 'found  ' : 'missing'} ${candidate}`);
  if (existsSync(candidate) || dir === '/') break;
  dir = dirname(dir);
}

console.log('resolved by the runtime:', import.meta.resolve('format-helper'));
```

```
$ node src/deep/search.mjs | sed "s|$(pwd -P)|.|"
missing ./src/deep/node_modules/format-helper
missing ./src/node_modules/format-helper
found   ./node_modules/format-helper
resolved by the runtime: file://./node_modules/format-helper/index.mjs
```

The `sed` call shortens the absolute path in the output; the last line gets the same
shortening. The search itself reveals four properties:

**It is location-dependent.** The same name can resolve to a different package for two
files in different directories. This is the basis of the version-conflict resolution
covered in a later lesson.

**It requires no registry.** Resolution is entirely local; it never reaches the
network. The network is used while packages are being placed into the directory, not
during resolution.

**The package itself speaks.** Once the directory is found, it is the package's own
manifest that says which file gets loaded.

**It is deterministic.** The same specifier in the same file tree always resolves to
the same file. This is the third guarantee listed in the first lesson of module
systems.

## The Exports Map

Once the package directory is found, it is the map in the manifest that determines
which paths are accessible from outside. A path not listed in the map cannot be
imported even if it exists in the directory.

The following two files sit in a separate consumer project that installs the
`text-measurer` package, and run inside that project's directory. The consumer's own
manifest — `consumer/package.json`:

```json
{
  "name": "consumer",
  "version": "1.0.0",
  "type": "module",
  "dependencies": { "text-measurer": "^0.1.0" }
}
```

```javascript
// file: consumer/use.mjs
import { measure } from 'text-measurer';
import { splitWords } from 'text-measurer/segmentation';

console.log(measure('One sentence. Two sentences.').wordCount);
console.log(splitWords('one two three').length);
```

```
$ node use.mjs
4
3
```

```javascript
// file: consumer/deep.mjs -- tries to reach an inner file not listed in the map
import { averageLength } from 'text-measurer/src/statistics.mjs';

console.log(averageLength(['one', 'two']));
```

```
$ node deep.mjs 2>&1 | grep -E '^Error' | sed "s|$(pwd -P)/||g"
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/statistics.mjs' is not defined by "exports" in node_modules/text-measurer/package.json imported from deep.mjs
```

This is encapsulation at the package level. With no map, every inner file is open to
the outside; consumers bind themselves to inner files, and reorganizing them turns
into a breaking change. The map separates which paths are contract and which files can
be freely changed.

## Dependency Types

In the manifest, dependencies are not a single list; each list answers a different
question.

**Runtime dependencies.** Packages needed while the package runs. Everyone who installs
the package installs these too.

**Development dependencies.** Needed only while developing the package: a test runner,
a linter, a build tool. The party consuming the package does not install these. The
point of the split is to keep the tree the consumer downloads small.

**Peer dependencies.** Dependencies the package needs to run, but which the package
itself must not install. If a plugin has to use the **same instance** of the library it
extends, it is written to this list; that way the party installing the library is the
application itself, and there is a single copy in the tree. Why a duplicate copy is a
problem is shown in the dependency tree lesson.

**Optional dependencies.** Dependencies whose failed installation does not stop the
process. The package has to carry a fallback path against their absence; if it does
not, this list has been used incorrectly.

## The Package Registry and the Lock File

A **package registry** is a directory that maps a name–version pair to an archive. The
content of a published name–version pair is considered immutable: the same pair should
always give the same bytes. This immutability is the foundation all later guarantees
rest on.

A range like `^1.4.0` in the manifest denotes not a single version but a set. A choice
is made from the set when install time comes. Two installs done at two different times
can make different choices as the set grows; the result is two trees that run
differently from the same source.

The **lock file** removes this uncertainty. It records three pieces of information:

1. **The exact version chosen.** Not a range, a single version number.
2. **The source address.** Where the archive was fetched from.
3. **The integrity digest.** A checksum of the archive's content.

The shape of the tree — which package sits at which level — is also recorded. Once a
lock file exists, installing is not a search, it is a reproduction: the written tree is
installed exactly as is.

This split comes from the same place as the Introduction to Linux course's framing of
dependency resolution as a constraint problem. The solution the resolver produces has
to be storable without recomputing it; the lock file is that record. This is why it is
added to version control: a second developer cloning a repository, and a build server,
both depend on installing the same tree.

## Summary

- A package is a directory carrying a manifest file; the manifest defines its identity,
  entry points, files to publish, and dependencies.
- A bare specifier is searched for in the `node_modules` directory at every level, from
  the importing file's directory toward the root; resolution is local, location-
  dependent, and deterministic.
- The exports map provides encapsulation at the package level; inner files not in the
  map cannot be imported from outside.
- Dependency lists answer different questions: runtime, development, peer, and
  optional.
- A version range denotes a set; the lock file makes installation reproducible by
  recording the choice made from the set, the source, and the integrity digest.

## Next Step

It was said that ranges denote a set, but not what draws the set's boundaries. Behind a
manifest that writes `^1.4.0` accepting version `1.9.0` and rejecting `2.0.0` is a
contract that assigns meaning to the parts of a version number. The next lesson builds
that contract and its comparison rules with running code.
