Validating Packages Before Publish
Catch broken exports maps, missing files, and wrong TypeScript types before npm publish using publint, are-the-types-wrong, and npm pack dry runs.
A package can build cleanly, pass every unit test, and still be broken for consumers the moment it reaches the registry — a typo in the exports field, a .d.ts file excluded from the files array, or a types condition that resolves to the wrong module format all pass npm publish without a single warning. This section covers the three checks — publint, are-the-types-wrong, and an npm pack dry run — that catch these failures before they ship, and how to run them as a required CI gate rather than a manual habit.
Prerequisites
Before wiring validation into your workflow, confirm:
Canonical Configuration Block
Run all three checks in sequence as a single npm script. Each command inspects a different layer of the published artifact: publint checks the manifest against the files on disk, attw checks what TypeScript actually resolves, and npm pack --dry-run checks what ends up in the tarball.
{
"scripts": {
"build": "tsup",
"prepublishOnly": "npm run build && npm run validate",
"validate": "publint --strict && attw --pack . && npm pack --dry-run"
}
}
# Run the full validation chain manually
npm run build
npx publint --strict
npx attw --pack .
npm pack --dry-run
prepublishOnly guarantees the chain runs automatically on npm publish, so a validation failure blocks the release even if a contributor forgets to run it by hand.
Validation Flow
Step-by-Step Implementation
Step 1 — Run publint against the package
publint reads package.json and checks every path referenced by main, exports, types, and files against what actually exists after a build. Run it from the package root, after building:
npx publint --strict
--strict upgrades suggestions (like recommending an exports field over main) to failures, which is the behavior you want in CI — a warning that’s silently ignored in a terminal is a bug that’s silently shipped to consumers.
HAZARD PREVENTION
Symptom:
publintpasses locally but fails in CI withCannot find file "./dist/index.cjs".Root cause: The local run reused a stale
dist/directory from a previous build; CI runs from a clean checkout where the build step hadn’t actually produced that file.Fix: Always run
npm run buildimmediately beforepublintin the same job step, and addclean: trueto your bundler config so stale artifacts can’t mask a broken build.
Step 2 — Run are-the-types-wrong against the packed tarball
attw simulates how TypeScript resolves your package under several moduleResolution settings at once — node10, node16 (both CJS and ESM), and bundler. The --pack flag packs the tarball first, so the check reflects exactly what npm would publish, not your working tree:
npx attw --pack .
A clean run reports every resolution mode with a green check. Any red or yellow entry means a real consumer using that TypeScript configuration will see broken or missing types.
Step 3 — Inspect the npm pack dry run
npm pack --dry-run prints the exact file list that would be published, computed from your files field, .npmignore, and npm’s built-in exclusions (like .git and node_modules). Compare this list against your exports map by hand, or grep for expected paths:
npm pack --dry-run 2>&1 | tee /tmp/pack-list.txt
grep "dist/esm/index.mjs" /tmp/pack-list.txt || (echo "Missing ESM entry file!" && exit 1)
grep "dist/cjs/index.cjs" /tmp/pack-list.txt || (echo "Missing CJS entry file!" && exit 1)
HAZARD PREVENTION
Symptom: Consumers report
Cannot find module 'my-lib/dist/esm/index.mjs'despite the file existing in your repository.Root cause: The
filesfield omitted thedist/esmsubdirectory, or a stray.npmignorerule (often inherited from a.gitignorecopy) excluded built output.Fix: Treat
npm pack --dry-runas the source of truth for what ships — a file existing in your git repository is irrelevant if it’s absent from the packed tarball.
Step 4 — Wire validation into CI
Run all three checks on every pull request, not just before a release, so exports regressions are caught the moment they’re introduced:
name: Validate Package
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- run: npx publint --strict
- run: npx attw --pack .
- run: npm pack --dry-run
Expected result: the job fails red on any exports typo, missing build artifact, or type-resolution gap — before a maintainer ever types npm publish.
Tooling Validation
Sample failing publint output for a package missing a CJS build artifact:
✗ [error] "exports['.']['require']" is defined but the file does not exist: ./dist/cjs/index.cjs
✗ [error] "exports['.']['require']" resolves to a file that doesn't exist
2 errors, 0 warnings
Sample passing attw --pack . output:
┌─────────────────┬──────────────┬──────────────┬──────────────┐
│ │ node10 │ node16 (cjs) │ node16 (esm) │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ . │ ✓ Resolved │ ✓ Resolved │ ✓ Resolved │
└─────────────────┴──────────────┴──────────────┴──────────────┘
Sample failing attw output caused by a masquerading types file (a .d.ts file served for an ESM-only entry, which TypeScript treats as CJS because of its extension):
┌─────────────────┬──────────────┬──────────────┬──────────────┐
│ │ node10 │ node16 (cjs) │ node16 (esm) │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ . │ ✗ Masquerading as CJS │ ✓ Resolved │
└─────────────────┴──────────────┴──────────────┴──────────────┘
Compatibility Matrix
| Tool | Minimum Node.js | Minimum TypeScript | Reads exports |
Reads packed tarball |
|---|---|---|---|---|
| publint | 18.x | n/a | Yes | Only with --pack (opt-in) |
| are-the-types-wrong (attw) | 18.x | 4.7+ (to have meaningful conditions) | Yes | Yes, with --pack . |
npm pack --dry-run |
16.x (npm 8+) | n/a | No (files field only) | Yes (this is its purpose) |
tsc --noEmit smoke test |
18.x | 4.7+ | Depends on moduleResolution |
No (filesystem only) |
Guides in This Section
- Using publint to Catch Exports Errors — a deeper look at the specific rules publint enforces and how to fix each class of error it reports.
- Checking Types with are-the-types-wrong — how to read the attw resolution matrix and fix masquerading types, missing conditions, and node10 fallback failures.
Related
- Mastering the package.json Exports Field — the configuration these tools validate; get the condition order right first, then verify it.
- Declaration File Generation and Type Stripping — how
.d.tsfiles are produced in the first place, which determines whether attw has anything valid to resolve. - Automating npm Releases with GitHub Actions — wire this validation chain into the same pipeline that performs the actual publish step.
- Versioning & Changelog Automation — once validation passes reliably, automate the version bump and changelog that precede publish.