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

publint validation flow Flowchart showing publint reading package.json, walking every path in the exports, main, and types fields, checking each against the filesystem, and classifying the result as an error, warning, or suggestion. Read package.json Walk exports paths Check against filesystem Classify each finding Error / warning / suggestion

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, so default — which always matches — wins before require is ever evaluated.

Fix: Enforce the fixed order typesimportrequiredefault in every exports branch. publint’s --strict flag 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 publint in the workspace root) rather than once at the repository root.
  • browser field packages. If you still ship a legacy browser field for pre-exports bundlers, publint flags it as redundant once a full exports map 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 link or a workspace symlink can produce different results than running it against the packed tarball — always confirm with --pack before trusting a clean local result.
  • False confidence from main alone. A package with only main and no exports field 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.



↑ Back to Validating Packages Before Publish