Skip to content
academia.sh

Lesson 07 / 14

Dependency Trees and Conflicts

How non-intersecting version requests are resolved by nested installation, the identity and state problems produced by duplicate copies, flattening the tree, and dependencies that require singularity.

Contents

The previous lesson examined a single range. A project holds dozens of packages, and most of them carry their own dependencies. It is ordinary for the same package to be requested from two different places with two different ranges.

If the requests intersect, there is no problem: a version is chosen from the intersection and everyone uses it. What happens when they do not intersect? That is this lesson’s subject — the solution to that case, and its cost.

Non-Intersecting Requests

The measurement project requests the second major version of a package named shared. The translator package the project uses, however, depends on the first major version of that same package. The intersection of the sets ^2.0.0 and ^1.0.0 is empty.

In system package managers, as described in the package manager lesson of the Introduction to Linux course, this results in a conflict report: only one version of a library can be installed system-wide. Module resolution, being location-dependent, offers a different way out — two versions of the same package can sit at two separate levels of the tree.

Two manifests show the conflicting requests in writing:

{
  "name": "project", "version": "1.0.0", "type": "module",
  "dependencies": { "translator": "^3.1.0", "shared": "^2.0.0" }
}
{
  "name": "translator", "version": "3.1.0", "type": "module", "main": "index.mjs",
  "dependencies": { "shared": "^1.0.0" }
}

The shape of the installed tree is this:

$ node tree.mjs
[email protected]
[email protected]
  [email protected]
duplicate: shared -> 2.0.0, 1.0.0

The script that produces this output applies the search rule from the previous lesson in reverse: it walks every node_modules directory and prints the manifests it finds.

// file: tree.mjs
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';

async function walk(dir, depth = 0, findings = new Map()) {
  const moduleDir = join(dir, 'node_modules');
  let entries;
  try {
    entries = await readdir(moduleDir);
  } catch {
    return findings;
  }
  for (const name of entries.sort()) {
    const packageDir = join(moduleDir, name);
    const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8'));
    console.log(`${'  '.repeat(depth)}${manifest.name}@${manifest.version}`);
    findings.set(manifest.name, (findings.get(manifest.name) ?? new Set()).add(manifest.version));
    await walk(packageDir, depth + 1, findings);
  }
  return findings;
}

const findings = await walk('.');
for (const [name, versions] of findings) {
  if (versions.size > 1) console.log(`duplicate: ${name} -> ${[...versions].join(', ')}`);
}

The placement in the tree is this: the second version of shared sits at the root directory, the first version sits inside translator’s own node_modules directory. By the search rule, an import made from inside translator looks in its own directory first and finds the first version; an import made from the root finds the second version. Both requests are satisfied.

The Cost of Two Copies

The conflict is resolved, but the result is two separate package instances. The single evaluation guarantee established in the module systems topic holds per resolved file path, not per package; two different files mean two different modules.

// file: node_modules/shared/index.mjs -- the second version
export const version = '2.0.0';
export class Record {
  constructor(name) { this.name = name; }
}
const records = [];
export function add(record) { records.push(record); return records.length; }
// file: node_modules/translator/index.mjs -- uses the first version from its own directory
import { add, Record, version } from 'shared';

export function use(name) {
  const record = new Record(name);
  return { seenVersion: version, recordCount: add(record), record };
}
// file: main.mjs -- uses the two packages above
import { add, Record, version } from 'shared';
import { use } from 'translator';

const ours = new Record('root');
console.log('version seen at root:', version, '| record count:', add(ours));

const theirs = use('inside-translator');
console.log('version seen by translator:', theirs.seenVersion, '| record count:', theirs.recordCount);
console.log('is the record translator produced an instance of our class:', theirs.record instanceof Record);
$ node main.mjs
version seen at root: 2.0.0 | record count: 1
version seen by translator: 1.0.0 | record count: 1
is the record translator produced an instance of our class: false

Three lines, three separate problems.

Version visibility depends on who is asking. Two different shared versions are running in the same program. Which one runs depends on where the question is asked from.

State is not shared. The two record lists are separate; both contain a single element. Anything of the cache or counter kind that the package holds is split. If a library offers something like “list all records,” it only sees its own copy’s records.

Identity checks fail. The object translator produces is not an instance of the root’s class. Even though the classes come from the same source, they are the product of two separate evaluations; object identity does not know this. The same problem shows up with symbols, error classes, and brand checks.

These three problems are a frequent cause of the “my program runs but behaves strangely somewhere” kind of hard-to-diagnose failure. Looking for a duplicate version in the dependency tree is the first step in diagnosing it.

Flattening

Nested installation is not always necessary. If the requests intersect, a single copy satisfies all of them, and that copy is placed at the root of the tree. This placement is called flattening.

Flattening has two justifications. The first is space and time: a single copy is installed instead of dozens of copies of the same package. The second is preventing the three problems just listed.

A side effect of flattening is that it sets the stage for the sneaky situation touched on in the previous lesson. A package hoisted to the root becomes visible even from modules that do not write it in their own manifest. The code runs locally; when the tree is built a different way — say another installation tool prefers nested placement — the package disappears. Hence the explicit rule: every used package has to be written in the manifest of the package using it.

Dependencies Requiring Singularity

For some packages, two copies are not merely wasteful, they are an outright bug. Libraries that keep a record, manage global state, or rely on object identity have to be a single instance.

The mechanism that provides this is the peer dependency list introduced in the first lesson. If a plugin writes the library it extends as a runtime dependency, it installs its own copy into its own directory and duality is created. Written as a peer dependency instead, it does not install the library; it only says “a version in this range has to be present in the environment.” Installing becomes the application’s responsibility, and a single copy remains in the tree.

The peer dependency range has to be chosen deliberately. Too narrow a range needlessly shrinks the set of versions the plugin can be used with and produces non-intersecting requests; too wide a range lets installation appear to succeed on versions where the plugin does not actually work.

When a Conflict Is Not Resolved

Nested installation does not resolve every conflict. In three situations a single copy is mandatory and a decision has to be made:

When peer dependencies do not intersect. If two plugins want incompatible major versions of the same library, the two cannot be used together. The fix is to upgrade or replace one of the plugins.

When values pass between packages. If the object one package produces is given as an argument to another package, and the receiving side does an identity check, two copies do not work.

When output size is decisive. Carrying two versions of the same library in output sent to the browser doubles the bytes downloaded.

In these situations, installation tools offer a manifest field that pins a transitive dependency’s version from outside. The field is useful but dangerous: forcing a package to run on a version other than the one it expects deliberately violates the version contract. When used it has to be justified with a comment and treated as temporary; the lasting fix is upgrading the incompatible package.

Summary

  • Non-intersecting version requests are satisfied by installing two copies of the same package at different levels of the tree; the search rule is what makes each consumer find its own copy.
  • The single evaluation guarantee holds per resolved file path, not per package; two copies keep separate state and fail object identity checks.
  • Intersecting requests are satisfied with a single copy, hoisted to the root; this flattening also makes packages not written in the manifest become visible.
  • Libraries requiring singularity are declared as peer dependencies; the responsibility of installing passes to the application and a single copy remains in the tree.
  • Pinning a transitive dependency’s version from outside violates the contract; it should be treated as temporary.

Next Step

Up to here, every package came from outside. A team’s own packages sit on the same tree too, and the dependencies among them cannot wait for a publish on every change. The next lesson covers the arrangement where multiple packages are kept in a single repository, how that arrangement changes resolution, and how the build order across packages is determined.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close