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.
| Field | Read by | What it does |
|---|---|---|
|
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. |
|
npm |
A SemVer string parseable by node-semver. Required (with |
|
Node.js |
|
|
Node.js and npm |
The default entry point when |
|
Node.js |
The modern entry-point map: subpaths, conditions and encapsulation. Takes precedence over |
|
Node.js |
Private |
|
npm |
Command name to file map; linked into |
|
npm |
Allow-list of what is packed into the tarball (see bin, files and engines). |
|
npm |
Advisory Node.js and npm version ranges (see bin, files and engines). |
|
npm |
Runtime and package manager expected by people working on the source, checked before |
|
npm |
|
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:
{
"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"
}
Source: npm Docs: package.json
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".
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:
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'))"
Source: npm Docs: npm pack
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:
npm install ../shelf24/bookshelf-shelf24-1.0.0.tgz
npm install --engine-strict ../shelf24/bookshelf-shelf24-1.0.0.tgz
Source: npm Docs: package.json (engines)
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:
{
"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:
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.
| Rule | Detail |
|---|---|
Relative URLs only |
Every target must be a string starting with |
No traversal |
A target may not resolve outside the package root, and |
Statically enumerable |
Because patterns are plain string replacement and |
Pattern trailers |
Text after |
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.
| Condition | Matches |
|---|---|
|
Any Node.js environment; use it to offer a native C++ addon entry. Disabled by the |
|
Any Node.js environment, CommonJS or ES module target. |
|
|
|
|
|
|
|
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:
{
"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:
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 theenvlines toenv-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-modulemade thesyncsubpath fall back tosync-fallback.cjs (default condition)for both loaders, includingimport: themodule-synccondition is switched off together withrequire(esm), so a CommonJS fallback behind it must be a complete, correct build. -
Loading the misordered subpath with
import()returnedfallback.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:
{
"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"
}
}
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).
{
"name": "@bookshelf/self",
"type": "module",
"exports": {
".": "./src/index.js",
"./util": "./src/util.js"
}
}
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:
{
"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:
// 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:
{
"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:
| Approach | Strength | Cost |
|---|---|---|
Two independent builds (hazard) |
Simple, works everywhere, correct for stateless pure functions. |
Two copies of any state, |
CommonJS source plus ESM wrapper |
One copy of state, works on every Node.js version that supports |
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. |
|
|
One ES module on Node.js with |
The CommonJS fallback can still produce a second copy on old runtimes; the ES module graph must not use top-level |
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.
{
"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:
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);
}
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 anullexclusion, and"types"for TypeScript; -
@bookshelf/settings: an ES-module-first package that usesmodule-syncwith CommonJS fallbacks, one of whose entry points uses top-levelawait; -
dep-node-native: a small CommonJS package that an application’s"imports"field maps to.
# 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: 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);
node probe-conditions.mjs
node -C development probe-conditions.mjs | tail -1
node --conditions=browser probe-conditions.mjs | tail -1
@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
Source: Node.js API: Packages, Conditional exports, Community Conditions Definitions, Subpath patterns and Node.js API: import.meta.resolve()
What the output shows:
-
Order decides between active conditions. With
-C developmentboth"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
nulltarget 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.jsonresolved tolocales/es.json.json, because the pattern appends.jsonto whatever*captured. Resolution succeeded even though that file does not exist; only loading it fails (next example). -
The internal layout is hidden.
src/node.jsexists 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: 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);
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.
{
"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"
}
}
}
// 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);
node main.js
node -C development main.js | tail -1
#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 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));
node consumer.cjs
node --no-experimental-require-module consumer.cjs
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)
Source: Node.js API: Loading ECMAScript modules using require(), Node.js API: process.features.require_module and Node.js API: ERR_REQUIRE_ASYNC_MODULE
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:
{ "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
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);
}
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
| Mistake | Consequence and fix |
|---|---|
Adding |
Consumers get |
|
The later keys never match. Order conditions from most to least specific. |
Using |
Every file in the package is importable and becomes part of your de-facto API. |
Forgetting |
Syntax detection works but costs a reparse and a warning, and tools that do not detect syntax guess differently. |
An |
The consumer’s install lacks the file. Use |
Treating |
It only warns unless the consumer sets |
A dual package with state |
Two copies of the state and failing |
|
|
Aliasing a built-in module in |
|
What changed recently
| Version | Change |
|---|---|
v12.7.0, v12.16.0, v12.19.0, v12.20.0 |
|
v22.9.0 and v20.18.0 |
The |
v22.7.0 and v20.19.0 |
Syntax detection enabled by default, so a package without |
v22.10.0 (and v20.19.0) |
New |
v22.12.0 and v20.19.0 |
|
v24.14.0 and v25.4.0 |
|
v24.20.0 and v26.4.0 |
Package maps added ( |
npm 12.2.0 documentation |
The |
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
-
Node.js API: Modules: Packages, https://nodejs.org/docs/latest/api/packages.html
-
Node.js API: Command-line options (
--conditions,--experimental-package-map,--no-addons), https://nodejs.org/docs/latest/api/cli.html -
Node.js API: Modules: CommonJS modules, https://nodejs.org/docs/latest/api/modules.html
-
Node.js API: Modules: ECMAScript modules, https://nodejs.org/docs/latest/api/esm.html
-
Node.js API: Modules: node:module API, https://nodejs.org/docs/latest/api/module.html
-
Node.js API: Errors (
ERR_PACKAGE_PATH_NOT_EXPORTED,ERR_PACKAGE_IMPORT_NOT_DEFINED,ERR_INVALID_PACKAGE_TARGET,ERR_PACKAGE_MAP_EXTERNAL_FILE), https://nodejs.org/docs/latest/api/errors.html -
Node.js API: Errors, ERR_PACKAGE_PATH_NOT_EXPORTED, https://nodejs.org/docs/latest/api/errors.html#err_package_path_not_exported
-
Node.js API: Errors, ERR_REQUIRE_ASYNC_MODULE, https://nodejs.org/docs/latest/api/errors.html#err_require_async_module
-
Node.js API: import.meta.resolve(), https://nodejs.org/docs/latest/api/esm.html#importmetaresolvespecifier
-
Node.js API: process.features.require_module, https://nodejs.org/docs/latest/api/process.html#processfeaturesrequire_module
-
Node.js release notes v22.10.0 (the
module-synccondition), https://nodejs.org/en/blog/release/v22.10.0 -
Node.js package examples repository (dual-package patterns), https://github.com/nodejs/package-examples
-
npm Docs: package.json, https://docs.npmjs.com/cli/v12/configuring-npm/package-json/
-
npm Docs: npm pack, https://docs.npmjs.com/cli/v12/commands/npm-pack
-
Node.js Learn: Publishing a package, https://nodejs.org/learn/modules/publishing-a-package
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/