package.json, exports & imports

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; all versions and stability levels were checked on 2026-10-03, the npm fields against the npm 12 documentation (npm 12.2.0), and every example below was run on all three Node.js lines unless the text says otherwise. A package.json file is read by two different programs. Node.js itself reads only five fields (name, main, type, exports and imports) to decide how to find and interpret a file, while npm reads many more (version, bin, files, engines, scripts, dependencies and so on) to install, pack and publish. Knowing which program owns which field is the key to this page: writing "engines" does nothing at run time, and writing "exports" does nothing to what npm packs. The Bookshelf application that grows across this section is the running scenario, here split into small packages so that resolution can be observed. Stability levels of what is covered: the package.json fields and the "exports" and "imports" machinery carry no stability marker in the API documentation and are stable in practice (they date from Node.js 12 and 14); syntax detection, which decides what happens when "type" is missing, is Stability 1.2 Release candidate; --conditions is Stable; "module-sync" is a regular condition implemented in core with no separate stability marker; and package maps with --experimental-package-map are Stability 1 Experimental (added in v26.4.0 and v24.20.0, not available in Node.js 22).

Which program reads which field

The API documentation says it plainly: the fields it documents are the ones the Node.js runtime uses, and "other tools (such as npm) use additional fields which are ignored by Node.js". The table lists the fields that matter to a package author and who acts on each.

Table 1. Fields of package.json by reader
Field Read by What it does

name

Node.js and npm

Package identity. Node.js uses it for self-referencing (see Self-referencing a package); npm uses it as the registry name. Up to 214 characters including the scope, no uppercase letters in new packages, and no non-URL-safe characters.

version

npm

A SemVer string parseable by node-semver. Required (with name) only if you publish.

type

Node.js

"module" or "commonjs": the format of .js files whose nearest parent package.json this is. Not used by npm.

main

Node.js and npm

The default entry point when "exports" is absent. Defaults to index.js. npm always includes this file in the tarball.

exports

Node.js

The modern entry-point map: subpaths, conditions and encapsulation. Takes precedence over main. See The exports field.

imports

Node.js

Private # aliases that only the package itself can use. See The imports field.

bin

npm

Command name to file map; linked into node_modules/.bin or the global bin directory. The file must start with a #!/usr/bin/env node line.

files

npm

Allow-list of what is packed into the tarball (see bin, files and engines).

engines

npm

Advisory Node.js and npm version ranges (see bin, files and engines).

devEngines

npm

Runtime and package manager expected by people working on the source, checked before install, ci and run.

private

npm

true makes npm publish refuse the package.

name, type and main

The fields you set first are name, type and main. Of these, type is the one that changes how your code is parsed, so it is worth seeing what each value does. The API documentation recommends that package authors always include the "type" field, even in packages whose sources are all CommonJS, because being explicit avoids a performance cost and future-proofs the package. This is a minimal manifest for a small library from the Bookshelf scenario:

A library manifest with type, main, bin, files and engines
{
  "name": "@bookshelf/shelf",
  "version": "1.0.0",
  "description": "Bookshelf catalogue helpers",
  "type": "module",
  "main": "./src/index.js",
  "bin": {
    "shelf": "./bin/shelf.js"
  },
  "files": [
    "src",
    "bin"
  ],
  "engines": {
    "node": ">=22.12"
  },
  "license": "MIT"
}

To observe "type" itself, the same one-file program (it uses import and checks typeof require) was put in three folders whose package.json was, respectively, missing the field, set to "module" and set to "commonjs".

One ES module source file in three packages
node typeless/main.js
node typed/main.js
node typedcjs/main.js

The results were identical on Node.js 26, 24 and 22. With "type": "module" the file ran as an ES module and printed undefined true / (require is not defined, import.meta.url is available). With the field missing, Node.js ran the file as an ES module too, because syntax detection (Stability 1.2) found import, but it first wrote a MODULE_TYPELESS_PACKAGE_JSON warning that says the file was reparsed as an ES module, "incurs a performance overhead" and that adding "type": "module" removes the warning. With "type": "commonjs" the file was parsed as CommonJS and failed with a SyntaxError on the import line, preceded by a warning that suggests setting "type": "module" or renaming the file to .mjs. Whatever the field says, .mjs is always an ES module and .cjs is always CommonJS. The full rules, including --input-type for --eval, are in ECMAScript Modules and CommonJS Modules; the JavaScript syntax is in JavaScript Modules.

"main" is the oldest field (Node.js 0.4) and has one weakness: it can name only one entry point and cannot prevent anyone from reaching other files in the package. "exports" fixes both, and when both are present the documentation states that "exports" takes precedence on every Node.js version that supports it. The API documentation advises that new packages targeting supported Node.js versions use "exports", and that "main" is only required for packages that support Node.js 10 and below; it also notes that you can keep a "main" next to "exports" pointing at the same file for older tools.

bin, files and engines

Three more fields are not read by the Node.js runtime at all but decide what your users actually receive.

bin. A map from command name to script, or a bare string when the command is named after the package. Installing the package links the file into node_modules/.bin (local installs) or the global bin directory, and npm exec/npx and npm run scripts find it by name. With the Bookshelf library installed as a dependency, npx shelf "Hello World" printed hello-world on Node.js 26.

files. An allow-list of file patterns with .gitignore-like syntax. If it is omitted it defaults to everything. Some files are always packed (package.json, the README, the LICENSE, the "main" file and the "bin" files), and some are never packed (for example .git, .npmrc, node_modules, package-lock.json, .DS_Store). A .npmignore file works like .gitignore but, at the root, does not override files. The fastest way to see what will ship is a dry run; the folder held an extra notes.txt and a test directory that are deliberately outside files:

Checking what npm will pack
npm pack --dry-run --json | node -e "const [p]=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(p.files.map(f=>f.path).join('\n'))"
bin/shelf.js
package.json
src/index.js

The listing was the same with npm 11 (Node.js 26 and 24) and npm 10 (Node.js 22).

engines. The Node.js and npm versions your package works on. The npm documentation calls it advisory: unless the engine-strict setting is on, it only produces a warning when your package is installed as a dependency, so it is a statement to your consumers and does not stop your own code from running on an older Node.js. A copy of the library declaring "node": ">=24" was installed on Node.js 22.23.3:

engines is a warning unless engine-strict is set
npm install ../shelf24/bookshelf-shelf24-1.0.0.tgz
npm install --engine-strict ../shelf24/bookshelf-shelf24-1.0.0.tgz
npm warn EBADENGINE   package: '@bookshelf/shelf24@1.0.0',
npm warn EBADENGINE   required: { node: '>=24' },
npm warn EBADENGINE   current: { node: 'v22.23.3', npm: '10.9.9' }
added 1 package, and audited 2 packages in 349ms

npm error code EBADENGINE
npm error engine Unsupported engine
npm error notsup Required: {"node":">=24"}
npm error notsup Actual:   {"npm":"10.9.9","node":"v22.23.3"}

The first command installed anyway; the second refused. On Node.js 26 the same install succeeded silently. For the people working on the source rather than consuming it, npm has a separate field, devEngines, that runs before install, ci and run; its keys are cpu, os, libc, runtime and packageManager, each with a name, an optional version and an optional onFail (warn, error or ignore, defaulting to error). A manifest requiring node >=24 with onFail set to error let npm run hello run on Node.js 26 (npm 11.19.1) and stopped it on Node.js 22 (npm 10.9.9) with EBADDEVENGINES. Pair this with the version managers described in Installing Node.js.

The exports field

"exports" turns a package from "a folder anyone can reach into" into "a declared public interface". It does three things: it defines the entry points, it can choose a different file per environment (conditions), and it makes every unlisted path unreachable by package name.

Main entry point and encapsulation

The shortest form is a string, which is sugar for the object form with a single "." key (sugar added in v12.11.0). The moment the field exists, every other subpath is encapsulated: requesting one throws ERR_PACKAGE_PATH_NOT_EXPORTED. The documentation also warns that introducing "exports" into an existing package is a breaking change for anyone who reached into it, including require('your-package/package.json'), so every entry point that used to work must be listed, or the release must be a major version.

Subpaths, patterns and null

Each key other than "." is a subpath that begins with ./. For packages with many modules, a pattern avoids listing each file. The is a plain string replacement, so it also matches nested paths with / in them (patterns date from v14.13.0 and v12.20.0). A target of null removes a matching path from a broader pattern. This package exposes a main file, a family of formatters under formats/, its own manifest, and hides a private folder:

Subpath exports with a pattern, an exclusion and the manifest
{
  "name": "@bookshelf/catalog",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.js",
    "./formats/*.js": "./src/formats/*.js",
    "./formats/private-internal/*": null,
    "./package.json": "./package.json"
  },
  "files": ["src"]
}

After packing and installing the tarball, a consumer script imported the allowed paths and then tried three forbidden ones:

Allowed and forbidden specifiers
import { name } from '@bookshelf/catalog';
import csv from '@bookshelf/catalog/formats/csv.js';
import pkg from '@bookshelf/catalog/package.json' with { type: 'json' };

console.log(name, csv(), pkg.version);

for (const specifier of [
  '@bookshelf/catalog/src/secret.js',
  '@bookshelf/catalog/formats/private-internal/keys.js',
  '@bookshelf/catalog/formats/csv',
]) {
  try {
    await import(specifier);
  } catch (error) {
    console.log(error.code, '<-', specifier);
  }
}
catalog main csv 1.0.0
ERR_PACKAGE_PATH_NOT_EXPORTED <- @bookshelf/catalog/src/secret.js
ERR_PACKAGE_PATH_NOT_EXPORTED <- @bookshelf/catalog/formats/private-internal/keys.js
ERR_PACKAGE_PATH_NOT_EXPORTED <- @bookshelf/catalog/formats/csv

The output was identical on Node.js 26, 24 and 22. Three details are worth noting. The extensionless form formats/csv failed because the pattern ends in .js on both sides and the match is exact string replacement, with no automatic extension handling; the documentation recommends choosing either the extensioned or the extensionless style per package so there is one specifier per module (and notes that explicit extensions keep import-map definitions small). require('@bookshelf/catalog/package.json') worked from CommonJS for the same reason that the ./package.json entry exists. And the encapsulation is not a sandbox: the documentation says a direct require() of an absolute path inside the package still loads it, which was confirmed by requiring the absolute path of src/secret.js. Treat "exports" as a contract, not a security boundary.

Table 2. Rules for export targets
Rule Detail

Relative URLs only

Every target must be a string starting with ./. /dist/main.js, file:///… and ../common/util.js are invalid.

No traversal

A target may not resolve outside the package root, and ., .. or node_modules segments (also URL-encoded) are disallowed after the initial ./ and in any substituted pattern part.

Statically enumerable

Because patterns are plain string replacement and node_modules paths are forbidden, the exported set can be derived from the package files alone, which tools rely on.

Pattern trailers

Text after * (such as .js) restricts the match; supported in "exports" since v16.9.0 and v14.19.0 and in "imports" since v16.10.0.

Conditions

A target can be an object whose keys are conditions. Node.js walks the keys in object order and takes the first whose condition is active, so order is part of the meaning of the file: most specific first, least specific last. The conditions that Node.js itself implements are listed here.

Table 3. Conditions implemented by Node.js
Condition Matches

"node-addons"

Any Node.js environment; use it to offer a native C++ addon entry. Disabled by the --no-addons flag, so keep a universal "default" (for example WebAssembly) as the fallback.

"node"

Any Node.js environment, CommonJS or ES module target.

"import"

import statements, import() and any top-level import or resolve by the ES module loader, whatever format the target file has. Mutually exclusive with "require".

"require"

require(). The target must be loadable by require() (CommonJS, JSON, a native addon or an ES module). Mutually exclusive with "import".

"module-sync"

import, import() and require() alike. The target is expected to be an ES module without top-level await in its graph; otherwise require() throws ERR_REQUIRE_ASYNC_MODULE. Added in v22.10.0 (and v20.19.0), and active only when require(esm) is enabled.

"default"

Always matches; must be last.

Four subpaths of one test package show what is picked. The first separates import from require, the second uses module-sync with a CommonJS default fallback, the third nests import and require under node and adds a custom development condition at the front, and the fourth is deliberately wrong:

A package with conditions of every kind
{
  "name": "@bookshelf/conditions",
  "version": "1.0.0",
  "exports": {
    "./dual": {
      "import": "./dual.mjs",
      "require": "./dual.cjs"
    },
    "./sync": {
      "module-sync": "./sync.mjs",
      "default": "./sync-fallback.cjs"
    },
    "./env": {
      "development": "./env-dev.cjs",
      "node": {
        "import": "./env-node.mjs",
        "require": "./env-node.cjs"
      },
      "default": "./env-generic.cjs"
    },
    "./misordered": {
      "default": "./fallback.cjs",
      "import": "./dual.mjs"
    }
  }
}

Each target file exports a string naming itself. A probe script loads every subpath both with import() and with require() (from createRequire), unwrapping the namespace object that require() returns for an ES module:

Probing each condition from both loaders
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
// require() of an ES module returns its namespace object, import() always does
const label = (exported) => (typeof exported === 'string' ? exported : exported.default);

for (const subpath of ['dual', 'sync', 'env']) {
  const specifier = `@bookshelf/conditions/${subpath}`;
  console.log(`${subpath.padEnd(5)} import  -> ${label(await import(specifier))}`);
  console.log(`${' '.repeat(5)} require -> ${label(require(specifier))}`);
}
dual  import  -> dual.mjs (import condition)
      require -> dual.cjs (require condition)
sync  import  -> sync.mjs (module-sync condition)
      require -> sync.mjs (module-sync condition)
env   import  -> env-node.mjs (node + import)
      require -> env-node.cjs (node + require)

Node.js 26, 24 and 22 printed exactly the same lines. Three variations were measured on top of this:

  • Adding --conditions=development (or -C development) on Node.js 26 changed the env lines to env-dev.cjs (development) for both loaders, because the custom condition comes first in that subpath. Custom conditions are the documented way to switch entry points per environment (they should contain only alphanumerics with :, - or = as separators, cannot start with ., contain , or be integer keys). Any number of them can be set by repeating the flag.

  • Starting Node.js 26 with --no-experimental-require-module made the sync subpath fall back to sync-fallback.cjs (default condition) for both loaders, including import: the module-sync condition is switched off together with require(esm), so a CommonJS fallback behind it must be a complete, correct build.

  • Loading the misordered subpath with import() returned fallback.cjs (default came first) on all three lines. "default" always matches, so any key placed after it is dead code. A package that puts "default" or a broad condition first silently disables its more specific branches.

The API documentation adds guidance worth following: always include a "default" branch when you branch on environments so unknown runtimes get the universal implementation, and prefer "node" plus "default" to "node" plus "browser". Beyond the conditions implemented in core, the documentation lists community conditions that other tools understand and Node.js ignores unless you enable them with -C: "types" (typing systems; it should always be included first), "browser", "development" and "production" (which must be mutually exclusive). For how TypeScript resolves these, see TypeScript tsconfig and compiler options; the flag itself is in CLI Options, Environment & Config File.

The imports field

"imports" is the private twin of "exports": a map of aliases, every one starting with , that apply only to import specifiers written inside the same package. The prefix guarantees that an alias cannot collide with a package name. It follows the same resolution rules as "exports" (conditions, patterns and trailers), and, unlike "exports", a target may be an external package, which lets a conditional alias swap an implementation per environment. It replaces the long relative paths (../../../db/books.js) and the NODE_PATH tricks that older applications used, and it works for ESM and CommonJS alike (require('#shelf') and require('#config') were verified on Node.js 26 and 22 in a "type": "commonjs" copy of this application).

This application manifest defines a conditional alias, a pattern alias and an alias that points at an installed package:

An application with imports
{
  "name": "@bookshelf/app",
  "private": true,
  "type": "module",
  "imports": {
    "#config": {
      "development": "./src/config.dev.js",
      "default": "./src/config.js"
    },
    "#db/*.js": "./src/db/*.js",
    "#shelf": "@bookshelf/shelf"
  },
  "dependencies": {
    "@bookshelf/shelf": "file:../shelf/bookshelf-shelf-1.0.0.tgz"
  }
}
Using the aliases, and an alias that does not exist
import { env } from '#config';
import { table } from '#db/books.js';
import { slug } from '#shelf';

console.log(env, table, slug('Node.js Projects'));

try {
  await import('#missing');
} catch (error) {
  console.log(error.code);
}
production books node-js-projects
ERR_PACKAGE_IMPORT_NOT_DEFINED

All three Node.js lines printed this, and running with --conditions=development printed development books node-js-projects as the first line. Two limits were measured. A target of node:sqlite (a built-in) for an alias failed with ERR_INVALID_PACKAGE_TARGET on Node.js 26 and 24, so alias the package that wraps the built-in instead, or import the node: module directly. And the aliases of a package are not visible to its dependents: # specifiers are resolved against the package.json nearest to the file that contains them.

Since v25.4.0 and v24.14.0 an alias may also start with /, which allows the familiar /lib/ style. With the entry "/lib/": "./src/lib/" a file containing import { upper } from '/lib/text.js' printed SHELF on Node.js 26 and 24, and on Node.js 22.23.3 it failed with ERR_INVALID_MODULE_SPECIFIER ("not a valid internal imports specifier name"), so a package that supports Node.js 22 must use #name forms (#lib/) only.

Self-referencing a package

A module inside a package can import the package by its own name and receive whatever "exports" publishes, with no relative path. This is useful in tests and examples because the import specifier is exactly what a consumer would write. It requires that "exports" exist (so a package without it cannot self-reference), it can only reach what "exports" allows, and it works with import, with require and with scoped names (self-referencing was unflagged in v13.6.0 and v12.16.0).

A package that tests its public interface by its own name
{
  "name": "@bookshelf/self",
  "type": "module",
  "exports": {
    ".": "./src/index.js",
    "./util": "./src/util.js"
  }
}
test/self.test.js, run with node test/self.test.js from the package root
import { title } from '@bookshelf/self';
import { trim } from '@bookshelf/self/util';

console.log(title, JSON.stringify(trim('  ok  ')));

try {
  await import('@bookshelf/self/src/private.js');
} catch (error) {
  console.log(error.code);
}
public entry point "ok"
ERR_PACKAGE_PATH_NOT_EXPORTED

The result was the same on all three lines, and a CommonJS file in the same folder doing require('@bookshelf/self/util') also worked. The self-reference resolved without installing the package anywhere.

Dual packages and the dual-package hazard

A dual package ships both an ES module build and a CommonJS build and picks one with the "import" and "require" conditions. It was the standard answer while require() could not load ES modules. It carries a real hazard: if an application ends up loading both builds (an ESM entry file imports the package while a CommonJS dependency requires it), then the process contains two copies of the package, with separate module state, and classes from one copy are not instanceof the other. The current API page for packages keeps only a pointer to the nodejs/package-examples repository for dual-package patterns, so the behaviour below was measured instead of quoted.

The first package is a deliberate counter-example: a stateful Book class and a lend() counter, written separately in esm/index.js and cjs/index.cjs and selected by condition:

The hazardous layout
{
  "name": "@bookshelf/hazard",
  "version": "1.0.0",
  "exports": {
    "import": "./esm/index.js",
    "require": "./cjs/index.cjs"
  }
}

The application’s ESM entry and a legacy CommonJS helper, which stands in for any dependency that was never converted, use the same package:

A legacy CommonJS dependency and an ESM entry
// legacy-hazard.cjs
const { Book, lend } = require('@bookshelf/hazard');

exports.makeBook = () => new Book('Legacy');
exports.lend = lend;

// check.mjs
const name = process.argv[2];
const { Book, lend } = await import(`@bookshelf/${name}`);
const { makeBook, lend: lendFromLegacy } = await import(`./legacy-${name}.cjs`);

const fromEsm = lend();
const fromCjs = lendFromLegacy();
console.log(`${name}: instanceof=${makeBook() instanceof Book} loans=${fromEsm},${fromCjs}`);

Two fixes were built next to it. The first keeps CommonJS as the single source of truth and exposes it to ESM through a small wrapper (esm/index.mjs containing import cjs from '../cjs/index.cjs'; export const { Book, lend } = cjs;), with "import" pointing at the wrapper and "require" at the CommonJS file. The second is ES-module-only with the module-sync condition and a CommonJS fallback for old Node.js:

The fix that relies on module-sync
{
  "name": "@bookshelf/hazard-esm",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    "module-sync": "./index.js",
    "default": "./dist/index.cjs"
  }
}
hazard: instanceof=false loans=1,1
hazard-wrapper: instanceof=true loans=1,2
hazard-esm: instanceof=true loans=1,2

The output is the same on Node.js 26, 24 and 22. In the hazardous package the legacy Book is not an instance of the ESM Book and each side counts loans from one. In both fixed packages there is one Book and one counter (1,2). The trade-offs:

Table 4. Ways to ship a package for both module systems
Approach Strength Cost

Two independent builds (hazard)

Simple, works everywhere, correct for stateless pure functions.

Two copies of any state, instanceof and identity checks fail across the boundary.

CommonJS source plus ESM wrapper

One copy of state, works on every Node.js version that supports "exports".

ESM consumers get only what named-export detection (or an explicit re-export list) can see; a maintained wrapper file.

ES module only, no CommonJS (a major-version break)

One build, tree-shaking friendly, simplest to maintain.

require() consumers need Node.js 22.12 or later (or 20.19 or later), or they must use import(). See ESM & CommonJS Interop.

module-sync plus a CommonJS default

One ES module on Node.js with require(esm), the CommonJS build only for old versions; avoids the hazard on new runtimes.

The CommonJS fallback can still produce a second copy on old runtimes; the ES module graph must not use top-level await.

The release notes of v22.10.0, which introduced the condition, describe it as a feature-detection mechanism for the period when some active LTS lines support require(esm) and some do not, and say that once every supported line does, a package can simplify its exports to the ES module alone. Since Node.js 20 reached its end of life on 2026-04-30 and 22 and later all support require(esm) without a flag, the condition matters mainly for libraries that still declare support for older runtimes. Bundlers use the de-facto "module" condition, and the same release notes suggest declaring both "module" and "module-sync" for the same ES module file when a library must serve bundlers and unbundled Node.js.

Package maps (experimental)

Package maps (Stability 1 Experimental, added in v26.4.0 and v24.20.0, not in Node.js 22) replace the node_modules directory walk with a single JSON file that says which package may import which other package. Enable them with --experimental-package-map=<path>. The documentation lists three motivations: monorepos with explicit dependency edges and no symlinks or hoisting, dependency isolation (a package cannot reach a dependency it did not declare, the so-called phantom dependency) and low file system coupling (resolution runs from static data tables). Only bare specifiers that are not built-in modules are affected; relative and absolute specifiers and node: imports keep the standard algorithm, and after the package location is found, Node.js applies the usual "exports", "main" and index.js rules to finish the resolution.

The file has a packages object whose keys are package IDs. Each entry has a required url (a file: URL or path, relative to the map file) and an optional dependencies object mapping the specifier used in source code to the ID of another package. Different packages can map the same specifier to different targets (two versions of one library side by side), and several IDs may share one URL, in which case the importer’s package ID must be known.

A three-package workspace with no node_modules, and its map
{
  "packages": {
    "app": {
      "url": "./packages/app",
      "dependencies": {
        "@bookshelf/utils": "utils",
        "@bookshelf/ui-lib": "ui-lib"
      }
    },
    "utils": {
      "url": "./packages/utils"
    },
    "ui-lib": {
      "url": "./packages/ui-lib",
      "dependencies": {
        "@bookshelf/utils": "utils"
      }
    }
  }
}

The app entry file imports both packages and then tries a package that nothing declares:

packages/app/main.js
import { greet } from '@bookshelf/utils';
import { banner } from '@bookshelf/ui-lib';

console.log(greet('world'), banner());

try {
  await import('left-pad');
} catch (error) {
  console.log(error.code);
}
Running with and without the map
node --experimental-package-map=./package-map.json packages/app/main.js
node packages/app/main.js

With the map, Node.js 26.10.0 and 24.21.0 wrote an ExperimentalWarning and printed hello world HELLO SHELF, then ERR_MODULE_NOT_FOUND for the undeclared package. Without it the first import failed with ERR_MODULE_NOT_FOUND because there is no node_modules. On Node.js 22.23.3 the flag is rejected (bad option). The documented limitations apply: the file is a single static document loaded synchronously at startup, there is no circular-dependency detection, and a file outside every mapped location fails with ERR_PACKAGE_MAP_EXTERNAL_FILE unless the importer’s package ID is supplied. Treat it as a feature to evaluate on tooling for monorepos (see Workspaces & Publishing) and not yet as a deployment format.

Worked examples: one lab, every rule

The sections above describe each rule with a fragment. This section puts them together in one small lab that you can recreate in an empty directory and run on any of the three supported lines. A shell script writes three packages directly into node_modules (which is where npm install would put them), so no registry or network access is needed:

  • @bookshelf/format: conditional exports per environment, a subpath pattern with a null exclusion, and "types" for TypeScript;

  • @bookshelf/settings: an ES-module-first package that uses module-sync with CommonJS fallbacks, one of whose entry points uses top-level await;

  • dep-node-native: a small CommonJS package that an application’s "imports" field maps to.

setup-lab.sh: creates the lab packages (run with sh setup-lab.sh)
# setup-lab.sh: creates the packages used below directly in node_modules (no registry needed)
set -e
P=node_modules/@bookshelf
mkdir -p $P/format/src $P/format/locales $P/settings/dist node_modules/dep-node-native

cat > $P/format/package.json <<'JSON'
{
  "name": "@bookshelf/format",
  "version": "2.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./src/index.d.ts",
      "browser": "./src/browser.js",
      "development": "./src/dev.js",
      "node": "./src/node.js",
      "default": "./src/browser.js"
    },
    "./locales/*": "./locales/*.json",
    "./locales/internal-*": null,
    "./package.json": "./package.json"
  }
}
JSON
echo "export const target = 'node.js';" > $P/format/src/node.js
echo "export const target = 'browser.js';" > $P/format/src/browser.js
echo "export const target = 'dev.js (extra checks)';" > $P/format/src/dev.js
echo 'export declare const target: string;' > $P/format/src/index.d.ts
echo '{ "hello": "Hello" }' > $P/format/locales/en.json
echo '{ "hello": "Hola" }' > $P/format/locales/es.json
echo '{ "key": "do not ship" }' > $P/format/locales/internal-keys.json

cat > $P/settings/package.json <<'JSON'
{
  "name": "@bookshelf/settings",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": { "module-sync": "./index.js", "default": "./dist/index.cjs" },
    "./remote": { "module-sync": "./remote.js", "default": "./dist/remote.cjs" }
  }
}
JSON
echo "export const source = 'index.js (ES module, synchronous graph)';" > $P/settings/index.js
printf '%s\n' "const config = await Promise.resolve({ region: 'eu' }); // top-level await" \
  'export const source = `remote.js (top-level await, region ${config.region})`;' > $P/settings/remote.js
echo "exports.source = 'dist/index.cjs (CommonJS fallback)';" > $P/settings/dist/index.cjs
echo "exports.source = 'dist/remote.cjs (CommonJS fallback)';" > $P/settings/dist/remote.cjs

echo '{ "name": "dep-node-native", "version": "1.0.0", "exports": "./index.js" }' > node_modules/dep-node-native/package.json
echo "exports.hash = (s) => 'native:' + s.length;" > node_modules/dep-node-native/index.js

Conditional exports per environment

@bookshelf/format offers one entry point with five branches. "types" comes first, as the community definition requires, and Node.js ignores it; "browser" and "development" are community conditions that Node.js ignores unless you enable them with -C; "node" is always active in Node.js; "default" catches every other runtime. import.meta.resolve() shows which file a specifier maps to without loading it, which makes it the quickest way to debug an "exports" map:

probe-conditions.mjs: resolve several specifiers, then load the main entry
// probe-conditions.mjs: which file does each specifier resolve to under the active conditions?
const show = (specifier) => {
  try {
    const url = import.meta.resolve(specifier);
    return url.slice(url.indexOf('@bookshelf/format/') + '@bookshelf/format/'.length);
  } catch (error) {
    return error.code;
  }
};
for (const s of ['@bookshelf/format', '@bookshelf/format/locales/es', '@bookshelf/format/locales/internal-keys',
                 '@bookshelf/format/locales/es.json', '@bookshelf/format/src/node.js']) {
  console.log(s.padEnd(42), '->', show(s));
}
const { target } = await import('@bookshelf/format');
console.log('loaded:', target);
Running it without a custom condition, then with two
node probe-conditions.mjs
node -C development probe-conditions.mjs | tail -1
node --conditions=browser probe-conditions.mjs | tail -1
Output (identical on Node.js 26, 24 and 22)
@bookshelf/format                          -> src/node.js
@bookshelf/format/locales/es               -> locales/es.json
@bookshelf/format/locales/internal-keys    -> ERR_PACKAGE_PATH_NOT_EXPORTED
@bookshelf/format/locales/es.json          -> locales/es.json.json
@bookshelf/format/src/node.js              -> ERR_PACKAGE_PATH_NOT_EXPORTED
loaded: node.js
loaded: dev.js (extra checks)
loaded: browser.js

What the output shows:

  • Order decides between active conditions. With -C development both "development" and "node" are active, and the development build wins because it is listed first. With -C browser, "browser" wins for the same reason, which is why a package that lists "browser" before "node" serves its browser build to anyone who runs Node.js with that flag.

  • A null target beats a broader pattern. ./locales/internal- is listed after ./locales/ and still wins: the longer, more specific key is matched first, not the first key in the object.

  • Patterns are text substitution. locales/es.json resolved to locales/es.json.json, because the pattern appends .json to whatever * captured. Resolution succeeded even though that file does not exist; only loading it fails (next example).

  • The internal layout is hidden. src/node.js exists on disk but is not reachable through the package name.

Reading the resolution errors

Each failure has a distinct code and a message that names the subpath and the package.json that refused it. The paths are shortened with replaceAll(process.cwd(), '.'):

errors.mjs: what import() and require() report for each kind of failure
// errors.mjs: the full messages behind the error codes
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

const attempts = {
  'import() of an unexported subpath': () => import('@bookshelf/format/src/node.js'),
  'require() of an unexported subpath': () => require('@bookshelf/format/src/node.js'),
  'import() of a null (excluded) subpath': () => import('@bookshelf/format/locales/internal-keys'),
  'import() of a pattern match whose file is missing': () => import('@bookshelf/format/locales/fr', { with: { type: 'json' } }),
};
for (const [what, attempt] of Object.entries(attempts)) {
  try {
    await attempt();
  } catch (error) {
    console.log(`${what}\n  ${error.code}: ${error.message.replaceAll(process.cwd(), '.')}`);
  }
}
const es = await import('@bookshelf/format/locales/es', { with: { type: 'json' } });
console.log('locales/es loaded:', es.default.hello);
Output (identical on Node.js 26, 24 and 22)
import() of an unexported subpath
  ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './src/node.js' is not defined by "exports" in ./node_modules/@bookshelf/format/package.json imported from ./errors.mjs
require() of an unexported subpath
  ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './src/node.js' is not defined by "exports" in ./node_modules/@bookshelf/format/package.json
import() of a null (excluded) subpath
  ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './locales/internal-keys' is not defined by "exports" in ./node_modules/@bookshelf/format/package.json imported from ./errors.mjs
import() of a pattern match whose file is missing
  ERR_MODULE_NOT_FOUND: Cannot find module './node_modules/@bookshelf/format/locales/fr.json' imported from ./errors.mjs
locales/es loaded: Hola

The distinction is useful when you debug a consumer’s bug report. ERR_PACKAGE_PATH_NOT_EXPORTED means the package’s "exports" does not allow the specifier (an excluded null path reads exactly like a path that was never listed), so the fix is in the manifest or in the consumer’s specifier. ERR_MODULE_NOT_FOUND after a successful match means the map is fine but the published files are not, typically a file left out of "files" or a build step that did not run. The require() message has no "imported from" part.

imports with conditions and an external target

The application below uses the pattern from the API documentation: a private alias that resolves to an installed package under "node" and to a local polyfill everywhere else, plus a pattern alias with a development branch.

app/package.json
{
  "name": "@bookshelf/app",
  "private": true,
  "type": "module",
  "imports": {
    "#hash": {
      "node": "dep-node-native",
      "default": "./src/hash-polyfill.js"
    },
    "#internal/*.js": {
      "development": "./src/internal/*.dev.js",
      "default": "./src/internal/*.js"
    }
  }
}
app/main.js, with app/src/hash-polyfill.js, app/src/internal/log.js and app/src/internal/log.dev.js each exporting one value
// main.js: conditional private aliases, resolved against this package.json only
import hashModule from '#hash';            // a CommonJS package under "node", a local file elsewhere
import { mode } from '#internal/log.js';    // a pattern alias with a per-environment branch

console.log('#hash ->', import.meta.resolve('#hash').replace(/^.*\/(node_modules|src)\//, '$1/'), '|', hashModule.hash('Dune'));
console.log('#internal/log.js ->', mode);
Running it from the app directory, then with -C development
node main.js
node -C development main.js | tail -1
Output (identical on Node.js 26, 24 and 22)
#hash -> node_modules/dep-node-native/index.js | native:4
#internal/log.js -> fast
#internal/log.js -> checked

#hash resolved to the package (found by the normal node_modules walk from the application’s directory), and the default import of a CommonJS package received its module.exports. The polyfill branch is never taken in Node.js, because "node" always matches there; it is what a bundler targeting browsers would pick. The same alias written in a dependency would not be visible to the application, and the application’s aliases are not visible to dep-node-native.

module-sync, require(esm) and top-level await

@bookshelf/settings is the ES-module-first layout from Dual packages and the dual-package hazard: each entry point lists the ES module under "module-sync" and a CommonJS build under "default". One of the two ES modules uses top-level await, which require() cannot wait for:

consumer.cjs: a CommonJS consumer
// consumer.cjs: a CommonJS consumer of an ES-module-first package
console.log('require(esm) enabled:', process.features.require_module);
console.log('require(".")       ->', require('@bookshelf/settings').source);
try {
  console.log('require("./remote") ->', require('@bookshelf/settings/remote').source);
} catch (error) {
  console.log('require("./remote") ->', error.code);
}
import('@bookshelf/settings/remote').then((m) => console.log('import("./remote")  ->', m.source));
Running it normally, then with require(esm) switched off
node consumer.cjs
node --no-experimental-require-module consumer.cjs
Output (the first block is identical on Node.js 26, 24 and 22; the second was measured on Node.js 26.10.0)
require(esm) enabled: true
require(".")       -> index.js (ES module, synchronous graph)
require("./remote") -> ERR_REQUIRE_ASYNC_MODULE
import("./remote")  -> remote.js (top-level await, region eu)

require(esm) enabled: false
require(".")       -> dist/index.cjs (CommonJS fallback)
require("./remote") -> dist/remote.cjs (CommonJS fallback)
import("./remote")  -> dist/remote.cjs (CommonJS fallback)

The second line of the first block is the case module-sync exists for: one ES module serves import and require() alike, so there is a single copy of its state and no dual-package hazard. The third line is the trap. "module-sync" matched, because the condition is active whenever require(esm) is, and Node.js does not fall back to "default" when the chosen ES module turns out to be asynchronous; the require() simply throws. So a module-sync target, and every module it imports, must avoid top-level await; keep asynchronous initialisation behind an exported function. import() is unaffected. The second block shows the fallback taking over for both loaders when require(esm) is off, which is the situation on runtimes that predate it, and the reason the CommonJS build must stay complete for as long as you support them.

Self-reference needs exports

Self-referencing resolves through "exports", so a package that only has "main" cannot import itself by name. Two otherwise identical packages show it, each with a test/self.test.js run from the package root:

with/package.json and without/package.json
{ "name": "@bookshelf/with-exports", "type": "module", "main": "./index.js", "exports": "./index.js" }
{ "name": "@bookshelf/without-exports", "type": "module", "main": "./index.js" }
with/test/self.test.js and without/test/self.test.js
// with/test/self.test.js
const { ok } = await import('@bookshelf/with-exports');
console.log('with "exports":', ok);

// without/test/self.test.js
try {
  await import('@bookshelf/without-exports');
} catch (error) {
  console.log('without "exports":', error.code);
}
Output (identical on Node.js 26, 24 and 22)
with "exports": true
without "exports": ERR_MODULE_NOT_FOUND

The package without "exports" falls through to an ordinary node_modules lookup for its own name, which finds nothing. Adding "exports" is enough to make self-reference work, and it is also the moment the package’s other files become unreachable from outside, so list every entry point you intend to keep.

Pitfalls

Table 5. Common mistakes with package.json resolution fields
Mistake Consequence and fix

Adding "exports" to a published package without listing the old subpaths

Consumers get ERR_PACKAGE_PATH_NOT_EXPORTED. List every previously reachable path, include ./package.json if tools read it, or release a major version.

"default" listed before "import" or "require"

The later keys never match. Order conditions from most to least specific.

Using "main" only

Every file in the package is importable and becomes part of your de-facto API.

Forgetting "type"

Syntax detection works but costs a reparse and a warning, and tools that do not detect syntax guess differently.

An "exports" target missing from files

The consumer’s install lacks the file. Use npm pack --dry-run before publishing.

Treating engines as enforcement

It only warns unless the consumer sets engine-strict; use devEngines for contributors and runtime checks for hard requirements.

A dual package with state

Two copies of the state and failing instanceof. Share one implementation as shown above.

"#/…​" aliases when Node.js 22 is supported

ERR_INVALID_MODULE_SPECIFIER on 22. Use #name aliases.

Aliasing a built-in module in "imports"

ERR_INVALID_PACKAGE_TARGET. Import the node: module directly.

What changed recently

Table 6. Timeline of changes relevant to this page
Version Change

v12.7.0, v12.16.0, v12.19.0, v12.20.0

"exports" introduced (v12.7.0); self-referencing unflagged (v13.6.0, v12.16.0); conditional exports unflagged (v13.7.0, v12.17.0); "imports" added (v14.6.0, v12.19.0); "exports" patterns (v14.13.0, v12.20.0).

v22.9.0 and v20.18.0

The --conditions flag is no longer experimental.

v22.7.0 and v20.19.0

Syntax detection enabled by default, so a package without "type" can run ES module syntax (with a performance warning).

v22.10.0 (and v20.19.0)

New "module-sync" condition.

v22.12.0 and v20.19.0

require(esm) enabled without a flag. It became Stability 2 Stable in v25.4.0.

v24.14.0 and v25.4.0

"imports" aliases starting with #/ allowed (not in Node.js 22).

v24.20.0 and v26.4.0

Package maps added (--experimental-package-map, Experimental).

npm 12.2.0 documentation

The "man" field is kept for compatibility but man pages are no longer registered with the system man program; a project-level packageExtensions field repairs third-party manifests (root package.json only, and npm refuses to publish a non-private package that contains it); devEngines is documented for contributors.

The history of the API page lists no change to the resolution rules of "exports" and "imports" after v25.4.0 and v24.14.0. Node.js 22, 24 and 26 behaved the same in every example above except for the #/ aliases and package maps, which are only available on 24 and 26.

Where the books are outdated

Efficient Node.js (Samer Buna, O’Reilly 2025) introduces package.json mainly as the place where dependencies are recorded, and explains ES modules with the "type": "module" switch and the .mjs and .cjs extensions. Two statements need updating. It says Node.js assumes .js files are CommonJS by default; that is still the nominal default, but with syntax detection (default since v22.7.0 and v20.19.0) an untyped package that uses import now runs as an ES module after a warning, as measured above. It also says a .mjs file can be imported into a CommonJS module; that has always worked through the dynamic import(), and since v22.12.0 and v20.19.0 a plain require() can load an ES module as well (stable since v25.4.0), and the package-level story ("exports" conditions, "module-sync", the dual-package hazard) is not covered.

Node.js Projects (Jonathan Wexler, O’Reilly 2025) generates a manifest with npm init, adds "type": "module" by hand and keeps "main": "index.js"; its appendix on project initialisation lists engines, exports, files and workspaces as fields that control deployment and compatibility but does not show how to write them. Today "main" is the fallback and "exports" is the field to learn: without it the package exposes every file. The appendix also does not mention "imports" aliases, which replace deep relative paths in an application, and treating engines as a guard is a common misreading, since npm only warns.

References