Before the "exports" field existed, every file in an npm package was public. If a package shipped lib/internal/parse.js, someone would import it directly, and the maintainer could never rename that file again without breaking them. The "main" field said where the entry point was, but it didn’t stop anyone going around it.
"exports" changes that. It’s a map in package.json from the paths consumers may import to the files that serve them, and Node refuses anything not on the map. It also lets one package serve different files to import and require. Interviewers ask about it at a beginner level because it’s now part of reading any modern package, and because the error it produces (ERR_PACKAGE_PATH_NOT_EXPORTED) is one people hit after upgrading a dependency.
A package with a deliberate public API
Here’s the package.json for a small money library. Everything in the examples was run with Node 22 from a project that has this package in node_modules:
{
"name": "@acme/money",
"version": "2.0.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
},
"./format": "./dist/esm/format.js",
"./features/*": "./dist/esm/features/*.js",
"./features/internal-*": null,
"./package.json": "./package.json"
}
}Reading it key by key:
"."is the package root, what you get fromimport '@acme/money'. It has conditions, so the file depends on how it’s loaded."./format"is a public subpath. Consumers write@acme/money/formatwith no extension and nodist/esmin the path, so the internal layout can change freely."./features/*"is a subpath pattern. The*captures any string and substitutes it into the target, so@acme/money/features/fxmaps to./dist/esm/features/fx.js."./features/internal-*": nullcarves an exception out of that pattern. Anulltarget means “not exported”, and the more specific key wins."./package.json"is exported explicitly. Some tools read a dependency’spackage.json, and without this line they can’t.
A script tried six imports against it:
const tryImport = async (spec) => {
try { const m = await import(spec); console.log(`ok ${spec.padEnd(36)} -> ${Object.keys(m).join(', ')}`); }
catch (e) { console.log(`fail ${spec.padEnd(36)} -> ${e.code}`); }
};
for (const spec of ['@acme/money', '@acme/money/format', '@acme/money/features/fx',
'@acme/money/features/internal-audit', '@acme/money/dist/internal/helper.js']) {
await tryImport(spec);
}ok @acme/money -> Money, flavour
ok @acme/money/format -> format
ok @acme/money/features/fx -> fx
fail @acme/money/features/internal-audit -> ERR_PACKAGE_PATH_NOT_EXPORTED
fail @acme/money/dist/internal/helper.js -> ERR_PACKAGE_PATH_NOT_EXPORTEDThe last line is the point of the whole feature. dist/internal/helper.js exists on disk, but the package didn’t export it, so it can’t be imported. The full message names the problem precisely:
Package subpath './dist/internal/helper.js' is not defined by "exports" in /node_modules/@acme/money/package.jsonThe block applies to require as well as import, and to every file not covered by a key, including package.json itself unless you export it. Importing JSON from ESM also needs an import attribute, import pkg from '@acme/money/package.json' with { type: 'json' }, which printed version 2.0.0 here.
Conditions and why their order matters
Inside a conditional entry, Node walks the keys in object order and picks the first one whose condition it recognises as active. Node itself recognises "import" (loaded with import or import()), "require" (loaded with require), "module-sync" in newer versions, "node", and "default", which always matches. Other tools add their own: TypeScript looks for "types", bundlers use "browser" or "development", and you can pass custom ones to Node with --conditions=name.
Because matching is first-wins in written order, order is part of the meaning:
"default"has to be last. Put it first and it matches everything, so later conditions never apply."types"should be first. TypeScript stops at the first condition it matches, so if"import"comes before"types", TypeScript resolves to the JavaScript file and finds no type information. That mistake produces the confusing “could not find a declaration file for module” error from a package that does ship types.
Conditions can nest, so "node": { "import": ..., "require": ... }, "default": ... serves Node-specific builds while browsers get the default.
Adding exports is a breaking change
Here’s the version-history trap. Suppose version 1 of a package had only "main", and users wrote require('@acme/money/lib/round') because it was useful. Version 1.5 adds an "exports" map listing only ".". Every one of those deep imports now fails with ERR_PACKAGE_PATH_NOT_EXPORTED, even though the file still exists.
So introducing "exports" belongs in a major version, or you export the old paths too. A common transitional trick is a wildcard that preserves the old layout, like "./lib/*": "./lib/*.js", deprecated in the docs and removed in the next major. On the consumer side, the fix is to find the public equivalent; if there’s no public API for what you need, that’s a feature request for the maintainer, not something to work around by pointing at file paths in node_modules.
Packages can also import themselves by name. Inside @acme/money, import { format } from '@acme/money/format' resolves through the package’s own "exports", which is a good way to make your tests use the public API exactly as consumers will.
The dual-package hazard
The conditional root entry above ships two builds: an ESM file for import and a CommonJS file for require. That’s convenient, and it creates a subtle problem when one application loads the package both ways, which happens easily when your code uses import and some dependency uses require:
const esm = await import('@acme/money');
const cjs = require('@acme/money'); // require made with createRequire(import.meta.url)
console.log('import flavour:', esm.flavour, '| require flavour:', cjs.flavour);
console.log('same class?', esm.Money === cjs.Money, '| instanceof across copies:', new cjs.Money(5) instanceof esm.Money);import flavour: esm | require flavour: cjs
same class? false | instanceof across copies: falseTwo separate files ran, so there are two Money classes, two sets of module-level state and two caches. A Money created by the CommonJS copy fails an instanceof check in code using the ESM copy. If the package keeps a registry or configuration at module level, configuring one copy doesn’t configure the other.
There are a few standard ways out. Ship one implementation and make the other entry a thin wrapper (for example, an ESM file that re-exports from the CommonJS build). Or keep state out of the module and in objects the caller passes around. Or, now that Node 22.12 and later can require() ES modules, ship ESM only, perhaps with the "module-sync" condition, and let CommonJS consumers load the same file.
Interview practice
After upgrading a dependency from 3.4 to 3.5 (a minor version), your build fails with Package subpath './lib/utils/retry' is not defined by "exports". The file is still present in node_modules. What happened, and what do you do?
Answer
Version 3.5 added an "exports" field (or narrowed an existing one) and didn’t list ./lib/utils/retry. With "exports" present, Node only resolves the subpaths it declares, whether or not the file exists, so our deep import into the package’s internals stopped working. Strictly speaking the maintainer made a breaking change in a minor release, but we were also depending on an internal path the package never promised to keep.
Short term, I’d pin the dependency to 3.4 to unblock the build. Then I’d look for a public replacement: check the new "exports" map, the changelog and the docs for a supported entry point that offers the retry helper. If there isn’t one, I’d either copy the small helper into our code or use a dedicated retry library, and open an issue asking the maintainer to export it. I wouldn’t patch the package’s package.json in node_modules or point at an absolute file path, since both break on the next install.