← Back to package.json Exports Drift Check
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.
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.
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.
| Shape | Handling |
|---|---|
| A literal path, any subpath, any condition nesting, any position in a fallback array | Checked 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 package | Skipped — nothing on disk to check |
main / module / types top-level fields | Not read at all in v0 — see the FAQ below |
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.
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.
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.
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.