Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

How publint evaluates a package publint collects the packed file list, reads every manifest field that points at a file, checks existence and format, and applies condition-order rules. packed file list what the tarball will contain manifest fields exports, main, module, types, bin existence + format file present? ESM or CJS as named? rules condition order, deprecated fields
Every path in the manifest is checked against the packed file list, not the working tree.

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

Common publint messages and their fixes Maps frequent publint findings to their cause and the manifest change that fixes each. Cause Fix File does not exist build output name differs from manifest align build fileName and exports path Not included in package files allowlist excludes it add the folder to files types is shadowed types condition not first move types to the top of each object ESM interpreted as CJS ESM in .js without type: module use .mjs or set type: module ./package.json not exported exports map omits it add "./package.json": "./package.json"
Nearly every message maps to one field in package.json and one concrete edit.

... 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.

publint catching a renamed build output A bundler upgrade changes output names; unit tests pass against source; publint checks the packed file list and fails before release. Bundler upgrade PR Unit tests publint Release job run against src/ check packed exports index.mjs does not exist blocked until manifest fixed
publint sees the tarball, so it notices renamed output that source-level tests cannot.

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 --strict on 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