Using publint to Catch Exports Errors
Run publint to detect exports map mistakes — missing files, wrong condition order, and CJS/ESM mismatches — before they reach npm consumers.
An exports map with a typo’d path, a missing build artifact, or a require condition pointing at a file that was never generated will not fail npm publish — the registry accepts whatever package.json says. The failure surfaces later, in a consumer’s install, as:
Error: Cannot find module '/node_modules/@acme/sdk/dist/cjs/index.cjs'
or the more opaque:
npm error code ERR_PACKAGE_PATH_NOT_EXPORTED
npm error Package subpath './utils' is not defined by "exports" in /node_modules/@acme/sdk/package.json
publint closes this gap by statically checking every path your exports field references against the actual files on disk (or in the packed tarball), before you publish, as part of the broader pre-publish validation workflow.
Root Cause
publint exists because package.json is declarative and unchecked — npm’s publish step never verifies that exports["."]["require"] actually resolves to a real file, that condition keys appear in the order Node.js expects, or that a package advertising "type": "module" doesn’t also ship a .js file full of require() calls. These mismatches are invisible in a git diff and invisible to npm publish, but immediately visible — and immediately broken — for the first consumer whose resolver walks a stale path. publint runs the same resolution logic Node.js and TypeScript use, ahead of time, against your local filesystem.
Minimal Reproduction
This package.json publishes cleanly but breaks every CJS consumer, because the referenced CJS file was never built:
{
"name": "leaky-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
}
$ ls dist/
index.d.ts index.mjs
# dist/index.cjs is missing — the build config never emitted a CJS target
npm publish succeeds regardless. npx publint catches it immediately:
$ npx publint
✗ [error] "exports['.']['require']" is defined but the file does not exist: ./dist/index.cjs
1 error, 0 warnings, 0 suggestions
How publint Checks an Exports Map
Step-by-Step Fix
Step 1 — Run publint and read the error class
npx publint
publint reports three severities: errors (broken resolution — will fail for real consumers), warnings (works today but fragile, such as using main alongside exports), and suggestions (style recommendations, like adding a types field for pre-4.7 TypeScript). Fix errors first; they represent a genuine 404 waiting for a user.
Step 2 — Fix the missing artifact
The reproduction above fails because the bundler config never emitted a CJS build. Update it to emit both formats:
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@
export default defineConfig({
entry: ['src/index.ts'],
- format: ['esm'],
+ format: ['esm', 'cjs'],
dts: true,
clean: true,
});
Rebuild and rerun publint:
npm run build
npx publint
✓ No issues found
Step 3 — Fix condition ordering, if flagged
publint also flags condition keys out of the required order. Node.js and TypeScript both stop at the first matching key, so default appearing before require silently shadows it:
"exports": {
".": {
- "default": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
- "require": "./dist/index.cjs"
+ "require": "./dist/index.cjs",
+ "default": "./dist/index.mjs"
}
}
HAZARD PREVENTION
Symptom: publint reports no errors, but a CJS consumer still receives the ESM build and crashes with a syntax error on
export.Root cause:
"default"was listed before"require"in the exports object. Both Node.js and bundlers stop at the first matching condition key, sodefault— which always matches — wins beforerequireis ever evaluated.Fix: Enforce the fixed order
types→import→require→defaultin every exports branch. publint’s--strictflag upgrades ordering issues from suggestions to errors so CI catches this automatically.
Step 4 — Run with --strict and --pack in CI
npx publint --strict --pack .
--strict turns every suggestion into a build-breaking error; --pack validates the npm tarball itself rather than the working directory, catching files field omissions that a plain filesystem check would miss.
Verification
npm run build
npx publint --strict --pack .
Expected output once the exports map is fully corrected:
✓ exports["."]["types"] resolves to ./dist/index.d.ts
✓ exports["."]["import"] resolves to ./dist/index.mjs
✓ exports["."]["require"] resolves to ./dist/index.cjs
✓ No issues found
Edge Cases / Gotchas
- Monorepos and pnpm workspaces. publint checks one package at a time; run it inside each package directory (or loop over
pnpm -r exec publintin the workspace root) rather than once at the repository root. browserfield packages. If you still ship a legacybrowserfield for pre-exportsbundlers, publint flags it as redundant once a fullexportsmap exists — this is a suggestion, not an error, and safe to keep if you support very old webpack configurations.- Symlinked local development. Running publint against a package linked via
npm linkor a workspace symlink can produce different results than running it against the packed tarball — always confirm with--packbefore trusting a clean local result. - False confidence from
mainalone. A package with onlymainand noexportsfield passes publint’s structural checks trivially, since there’s no condition ordering to validate — this doesn’t mean the package is dual-format safe, only that it has nothing conditional to break.
Frequently Asked Questions
Does publint replace the need for are-the-types-wrong?
No. publint checks that files referenced by package.json exist and that the exports map is structurally valid; it does not simulate TypeScript’s module resolution. Run attw alongside publint to catch cases where files exist but TypeScript still resolves the wrong types.
Why does publint warn about a main field I still need for old bundlers?
publint treats a redundant main field as a suggestion, not an error, because some pre-conditional-exports tooling still reads it as a fallback. If you intentionally keep main for legacy compatibility, silence the specific rule with an inline publint config rather than disabling --strict entirely.
Can publint check a package I haven’t published yet?
Yes. Run it from the package’s root directory after a build; it reads the local package.json and filesystem directly. Use the --pack flag to instead validate the exact contents of the tarball npm pack would produce, which also catches files field omissions.
Does publint understand pnpm workspaces?
publint operates per-package, so in a pnpm workspace you run it inside each package directory (or via a script that loops over workspace packages). It does not resolve workspace: protocol dependencies specially, since those are irrelevant to what an external consumer’s resolver sees.
Related
- Validating Packages Before Publish — the full pre-publish checklist this guide is one part of, including attw and npm pack dry runs.
- Checking Types with are-the-types-wrong — the complementary tool for verifying TypeScript resolution once publint confirms the files themselves exist.
- Mastering the package.json Exports Field — the reference for correct condition ordering that publint enforces.