← Back to package.json Exports Drift Check

Why package.json exports fields drift from what you actually publish

Node's exports field in package.json is a contract: for every subpath it names, there is exactly one file (per condition) that a consumer's import or require resolves to. Unlike Node's older, more forgiving file resolution, a broken entry here doesn't degrade gracefully — it throws ERR_PACKAGE_PATH_NOT_EXPORTED or Cannot find module the instant something tries to use it.

Why it happens: the map is authored once, the build output changes underneath it

A typical library builds ESM and CommonJS output into separate directories, points exports at both, and ships. Then a build tool upgrade renames an output directory, a refactor moves the entry file, or a maintainer runs npm publish straight after a source edit without re-running the build — and nothing forces exports to be re-checked against what the build actually produced. It is the same shape as a changelog that stops matching the commits it describes, or an .env.example that stops matching the code that reads it: one file, hand-authored, drifting from an artifact that changes on its own schedule.

Why this bug is unusually invisible to the maintainer

On the maintainer's own machine, the build has almost always already run by the time they test anything, so the exact file exports names is sitting right there and everything works. The gap is between what's on disk locally right now and what ends up in the tarball a stranger installs — a difference the maintainer has no natural reason to look at, because their own environment never reproduces it. The first person to find out is whoever runs npm install next.

What this tool checks, and what it deliberately doesn't

ShapeHandling
A literal path, any subpath, any condition nesting, any position in a fallback arrayChecked against the file listing / filesystem
A wildcard target (./features/*)Skipped — needs pattern matching against real files, out of scope for v0
A bare specifier re-exporting another packageSkipped — nothing on disk to check
main / module / types top-level fieldsNot read at all in v0 — see the FAQ below

Wiring it into CI as the last gate before publish

Run it as the final step of your publish job, after the build and before npm publish — running it earlier will read every export as missing, because the build hasn't produced anything yet:

- run: npm run build
- run: node ventures/package-exports-drift-check/cli.mjs check
- run: npm publish

The command exits non-zero if any literal export target doesn't exist on disk relative to package.json. For extra confidence against what will actually ship (as opposed to what's on disk), pair it with npm pack --dry-run's file list pasted into the browser tool.

Already run publint as part of your publish checks? See how this tool and publint compare — different scope, and this one needs nothing installed or published.

Common questions

Isn't this what "files" and .npmignore are for?

Those control which files get INTO the tarball. This tool checks a separate question: whether the paths named in "exports" match files that actually exist at all, in the location you're checking (your working tree, or a listing you took from the tarball itself). A file can be correctly included by "files" and still be at the wrong path relative to what "exports" claims — that mismatch is what this catches.

Does npm or Node check this for me at publish time?

No. npm publish packs whatever "files" (or its defaults) selects and uploads it; it does not read "exports" and verify every target resolves to a real file in that tarball. Node only discovers a broken exports target the moment something tries to import it — which, for most maintainers, is the first bug report from a user, not a step in the publish itself.

What about the "main", "module", and "types" fields?

Out of scope for v0, on purpose. "exports" is the field that changed Node's resolution behaviour and is the sharpest, most all-or-nothing failure mode — a wrong entry there can make an entire subpath unimportable rather than just a fallback. "main"/"module"/"types" pointing at missing files is a real but softer version of the same problem, and TypeScript's own tooling (tsc, arethetypeswrong) already checks the "types" case specifically.