Validating Package Exports with publint
publint is a linter for the thing most linters ignore: your published package.json and the files it points at. It checks that every exports target exists, that conditions are ordered correctly, that main, module, types and bin agree with the files in the tarball, and that module formats match their file extensions. It runs in seconds and catches the class of mistakes that otherwise only surface as consumer bug reports. This guide shows how to run it, how to read its messages, and how to make it a CI gate for every package in a workspace.
What publint checks
publint reads your package the way consumers' tools will: it packs (or reads the packed file list), follows every path in the manifest, and applies the resolution rules of Node.js and common bundlers. Typical findings:
$ npx publint
Running publint v0.3.x for @acme/ui...
Errors:
1. pkg.exports["."].import is ./dist/index.mjs but the file does not exist.
2. pkg.bin is ./dist/cli.js but the file is not included in the published package.
Warnings:
1. pkg.exports["."].types should be the first in the object as conditions are order-sensitive. It is currently shadowed by pkg.exports["."].import.
2. pkg.main is ./dist/index.js and is written in ESM, but is interpreted as CJS. Consider using the .mjs extension, e.g. ./dist/index.mjs
Suggestions:
1. pkg.exports["./package.json"] is not exported. Consider adding it so tools can read it.
2. The package does not specify the "type" field. Node.js may attempt to detect the package type causing a small performance hit. Consider adding "type": "commonjs".
The categories matter: errors are almost always real breakage for some consumer; warnings are likely breakage or ambiguity; suggestions are best practice.
Running it
# Current directory, packing with the detected package manager
npx publint
# A specific package folder in a workspace
npx publint packages/ui
# Treat warnings as errors — recommended for CI
npx publint --strict
# Lint a tarball you already built (the exact release artefact)
npm pack && npx publint ./acme-ui-2.0.0.tgz
Linting the tarball is the most faithful mode: it checks the file list your release job will actually publish, including anything your prepack step generated. For the rest of the pre-publish checks, see Testing and Validating Packages Before Publishing.
Reading and fixing the common messages
... but the file does not exist — the build emits a different name than the manifest expects. This happens after build tool upgrades that change default extensions (for example, index.mjs versus index.js). Align the manifest with what the build produces, and let the pack check in CI assert it.
... is not included in the published package — the file exists locally but your files field or ignore rules exclude it. Fix the allowlist; the mechanics are in Choosing Between the files Field and .npmignore.
types should be the first in the object — conditions are matched in order, and TypeScript stops at the first condition it recognises. With import above types, TypeScript picks the JavaScript file and fails to find types. Move types to the top of each condition object.
is written in ESM, but is interpreted as CJS — a .js file contains import/export, but the package has no "type": "module", so Node.js loads it as CommonJS and throws SyntaxError: Cannot use import statement outside a module. Rename to .mjs or set the package type; see Fixing 'Cannot use import statement outside a module'.
pkg.module is used but pkg.exports is present style messages — legacy fields are ignored by modern resolvers when exports exists. They are harmless for old bundlers but should not be relied on.
Worked example: a build tool upgrade that renamed every file
A library upgrades its bundler across a major version. The new version changes the default output name for ESM from index.mjs to index.js in "type": "module" packages. Tests pass — they run against source. publint in CI fails immediately:
Errors:
1. pkg.exports["."].import is ./dist/index.mjs but the file does not exist.
2. pkg.exports["./server"].import is ./dist/server.mjs but the file does not exist.
Without publint, the release would have shipped a package whose every ESM import failed with ERR_MODULE_NOT_FOUND. The fix is a one-line change per entry in exports, and the pull request that upgraded the bundler now contains the manifest update that goes with it.
What publint does not check
Knowing the edges of the tool keeps you from over-trusting a green run. publint does not execute your code, so a file that exists and has the right extension can still throw on import — a missing runtime dependency, a Node.js built-in in a browser build, or top-level await that breaks require(esm). It checks type paths and ordering but not whether the declarations describe the right module format for each condition; that is Are the Types Wrong's job. It does not know which Node.js versions you support, so a file using import.meta.dirname passes publint and fails on Node.js 18. And it cannot tell whether your exports map exposes the right entry points — only whether the ones you listed are valid.
Those gaps are exactly what the later layers of the validation stack cover: fixture installs execute the code, attw verifies type formats, and the runtime matrix covers versions. publint's role is to make the cheap, mechanical mistakes impossible, so the expensive checks spend their time on real problems.
Legacy fields and what to do with them
Older packages often carry fields that modern resolvers ignore once exports exists: module (a bundler convention for an ESM entry), browser as a top-level string or object, jsnext:main, and typings as an alias of types. publint flags inconsistencies between these and exports — for example, a module field pointing at a file that no longer exists after a build change. The pragmatic approach is to keep main and types for old tools, express everything else through exports conditions (import, require, browser, types), and delete the rest once your supported bundlers all read exports. Every field you remove is one less place for publint to find a mismatch.
Workspaces and CI
Run publint for every publishable package. With pnpm:
pnpm -r --filter "!./apps/**" exec publint --strict
Or as a task-runner task so it is cached and only runs for changed packages:
{
"tasks": {
"lint:package": {
"dependsOn": ["build"],
"inputs": ["package.json", "dist/**"],
"outputs": []
}
}
}
And in each published package, "lint:package": "publint --strict". Private packages can be skipped entirely — publint reports nothing useful for packages that are never published.
A full CI job:
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build lint:package --filter="...[origin/main]"
Using publint programmatically
For custom release tooling, publint exposes a JavaScript API that returns structured messages, which you can filter or format:
import { publint } from 'publint';
import { formatMessage } from 'publint/utils';
import { readFile } from 'node:fs/promises';
const pkg = JSON.parse(await readFile('packages/ui/package.json', 'utf8'));
const { messages } = await publint({ pkgDir: 'packages/ui', level: 'warning', strict: true });
for (const m of messages) console.log(`${m.type}: ${formatMessage(m, pkg)}`);
if (messages.some((m) => m.type === 'error' || m.type === 'warning')) process.exit(1);
This is useful when a release script already iterates over packages and wants one combined report.
Prevention and CI/CD guardrails
- Run
publint --stricton every pull request that touches a published package. - Lint the release tarball, not just the directory, in the release job.
- Treat build tool upgrades as manifest changes, and expect publint to be the first check to notice.
- Pair publint with Are the Types Wrong, which covers type resolution that publint only partly checks.
Frequently Asked Questions
What is the difference between publint and Are the Types Wrong? publint validates the manifest and files against runtime resolution rules and packaging conventions. Are the Types Wrong focuses on whether TypeScript resolves correct declarations for every entry under each module resolution mode. They overlap slightly and complement each other.
Can publint fix issues automatically? No. Each message names the field and the problem; fixes are manual, usually a one-line manifest change.
Does publint need the package to be built? Yes. It checks files the manifest points at, so run it after your build. In CI, make the publint task depend on the build task.
Is publint useful for applications? Not really. Applications are not published, so there is no manifest contract with consumers. Use it for packages you publish, including internal packages on a private registry.
Should suggestions fail the build?
Usually not at first. Start with --strict (errors and warnings fail), fix the suggestions opportunistically, and consider failing on them once a package is clean so it stays that way.
Related
- Testing and Validating Packages Before Publishing places publint in the full validation stack.
- Checking Published Types with Are the Types Wrong covers the type-resolution checks publint does not.
- Understanding package.json Fields explains the fields publint validates.
- Fixing ERR_PACKAGE_PATH_NOT_EXPORTED shows a runtime failure publint helps prevent.