Fixing ERR_PACKAGE_PATH_NOT_EXPORTED
Diagnose and fix Node's ERR_PACKAGE_PATH_NOT_EXPORTED: missing subpath keys, absent package.json exports, trailing-slash entries, and consumer-side workarounds.
The error names a file that plainly exists:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './utils' is not defined by
"exports" in /app/node_modules/@scope/my-library/package.json
at exportsNotFound (node:internal/modules/esm/resolve:295:10)
ls node_modules/@scope/my-library/dist/utils.mjs confirms the file is there. It is unreachable anyway, because an exports map replaces file existence with an explicit allowlist, and this specifier is not on it.
Root Cause
Before a package has an exports field, Node resolves subpaths as filesystem paths. Once the field exists, the resolver consults only the map: it finds the longest matching key, applies the conditions, and resolves the target. A specifier with no matching key does not fall back to the filesystem — it raises this error.
Four situations produce it, and they need different fixes. The subpath is genuinely missing from the map; the map uses a removed syntax such as a trailing-slash directory entry; the specifier reaches into a build directory that was never intended to be public; or the requested path is package.json itself, which many tools read at runtime.
Minimal Reproduction
{
"name": "@scope/my-library",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
}
},
"files": ["dist"]
}
// consumer.ts
import { formatBytes } from "@scope/my-library/utils"; // ERR_PACKAGE_PATH_NOT_EXPORTED
dist/index.mjs
dist/utils.mjs ← shipped, and unreachable
Which of the Four Cases You Have
Step-by-Step Fix
1. Print the map and compare it with the failing specifier
node -p "JSON.stringify(require('@scope/my-library/package.json').exports, null, 2)" 2>/dev/null \
|| node -p "JSON.stringify(require('./node_modules/@scope/my-library/package.json').exports, null, 2)"
If the first command itself fails with the same error, you already have case four — the manifest is not exported.
2. Add the missing subpath with a full condition object
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
- }
+ },
+ "./utils": {
+ "types": "./dist/utils.d.ts",
+ "import": "./dist/utils.mjs",
+ "require": "./dist/utils.cjs",
+ "default": "./dist/utils.mjs"
+ },
+ "./package.json": "./package.json"
}
node --input-type=module -e "
const { formatBytes } = await import('@scope/my-library/utils');
console.log('resolved:', typeof formatBytes);
"
Expected output: resolved: function.
3. Replace removed directory syntax with a pattern
- "./plugins/": "./dist/plugins/"
+ "./plugins/*": {
+ "types": "./dist/plugins/*.d.ts",
+ "import": "./dist/plugins/*.mjs",
+ "default": "./dist/plugins/*.mjs"
+ }
Trailing-slash entries were removed from Node.js and match nothing in current versions, which is why every path under that directory produces this error while the map appears to cover them.
4. Verify the target files actually ship
An entry pointing at a file excluded from the tarball produces a different error for the consumer — ERR_MODULE_NOT_FOUND — but the same support thread, so check both together:
npm pack --dry-run --json | node -e '
const files = new Set(JSON.parse(require("fs").readFileSync(0,"utf8"))[0].files.map(f => f.path));
const refs = [...new Set(JSON.stringify(require("./package.json").exports).match(/\.\/[\w./-]+/g) ?? [])];
const missing = refs.map(r => r.slice(2)).filter(p => !files.has(p) && !p.includes("*"));
console.log(missing.length ? "will not ship: " + missing.join(", ") : "every exported path ships");
'
HAZARD PREVENTION
Symptom: A consumer’s build fails with this error naming
./package.json, in a stack trace that points at a bundler plugin rather than their own code.Root cause: A build tool read your manifest at runtime to discover the version or a field, and the map does not expose it.
Fix: Add
"./package.json": "./package.json"to the map. There is no downside — the file is already in the tarball and readable — and it prevents an entire genre of confusing third-party failure.
Verification
# every documented entry point resolves in a real install
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent
for p in "" "/utils" "/package.json"; do
node --input-type=module -e "await import('@scope/my-library$p')" \
&& echo "ok: @scope/my-library$p" || echo "FAIL: @scope/my-library$p"
done
npx publint --strict
Expected: an ok: line per entry point and a clean publint run.
Edge Cases / Gotchas
- The error can come from a dependency of a dependency. The specifier named in the message may belong to a package you do not control; the fix is then an issue upstream, or a temporary patch on the installed manifest.
- Bundlers report it differently. Vite and webpack surface their own message for the same condition, so a consumer’s wording may not match the Node.js text even though the cause is identical.
- Adding an entry is a minor release; removing one is major. Treat the map as public API — every key you add, you support.
- Case sensitivity bites on macOS. A map key differing only in case from the requested specifier resolves on a case-insensitive filesystem and fails in CI.
- A
types-only entry does not fix a runtime failure. If the consumer’s error is at runtime, adding atypescondition changes nothing; the runtime conditions are what must exist.
One habit prevents most recurrences: whenever a subpath is added to the map, add it to the verification loop in the same commit. The list of entry points a package claims to support and the list it is tested against should be the same list, and keeping them in one place — a small array in the release script — means a new subpath cannot be documented without also being exercised. Packages accumulate this error precisely because the two lists drift, usually when a subpath is added to satisfy one consumer and never enters the release checklist at all.
Frequently Asked Questions
Why does the file exist but not resolve?
Once a package has an exports field, file existence is no longer the criterion. The resolver consults only the map, so an unlisted path is unreachable by design.
How do I find which subpath a consumer needs?
The error names the exact specifier. Compare it with the map’s keys; if the specifier is a deep path into a build directory, the answer is a public subpath rather than an entry for that path.
Can a consumer work around a missing export?
Yes, by patching the installed manifest or importing a different public entry point — both stopgaps, since the patch needs maintaining and the alternative entry may pull in much more code.
Why do trailing-slash directory exports not work?
They were an early form of directory export that Node.js removed. Such an entry matches nothing, so every path under it produces this error.
Should package.json itself be exported?
Yes. Many build tools read a dependency’s manifest at runtime, and omitting it produces this error from inside somebody else’s tooling.
Related
- Mastering the package.json Exports Field — the condition and key semantics behind the fix.
- Subpath Exports and Deep Import Patterns — designing the surface so this error stops occurring.
- Using publint to Catch Exports Errors — catching the same problem before publishing.