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) |
What Each Validator Actually Checks
The four tools in a pre-publish chain look interchangeable from the outside — they all print a list of problems — but they inspect completely different layers of the package, and running only one of them leaves a predictable class of bug alive. Knowing which layer each covers is what turns the chain from a ritual into a real gate.
The blind spots stack in a useful way. tsc --noEmit can pass on a package whose exports map points at a file that does not exist, because the compiler never reads the manifest’s runtime conditions. publint catches that missing file, but happily accepts a types condition that resolves to declarations describing a completely different API. arethetypeswrong catches that, because it resolves the package the way each consumer mode does — but it inspects what it is given, so a file excluded by an over-tight files array can still slip past if the tool runs against your working directory rather than the packed tarball. Only npm pack settles the question of what actually ships.
That last point is why the order matters: run attw --pack . rather than attw ., so the type resolution is evaluated against the tarball contents instead of the source tree. The two differ far more often than authors expect, and the difference is always in the same direction — the working directory contains files the tarball does not.
Inspecting the Tarball Before It Leaves Your Machine
npm pack --dry-run prints the file list npm would upload without producing an artefact, and reading that list carefully is the single highest-value pre-publish habit. Three failure modes show up there and nowhere else: missing runtime files, accidentally included secrets, and a tarball bloated by test fixtures.
npm pack --dry-run 2>&1 | sed -n '/Tarball Contents/,/Tarball Details/p'
npm notice === Tarball Contents ===
npm notice 1.2kB package.json
npm notice 4.4kB README.md
npm notice 18.9kB dist/index.mjs
npm notice 19.4kB dist/index.cjs
npm notice 6.1kB dist/index.d.ts
npm notice 6.1kB dist/index.d.cts
npm notice === Tarball Details ===
npm notice package size: 14.8 kB
npm notice unpacked size: 56.1 kB
npm notice total files: 6
Six files, no source, no tests, both module formats, and a declaration file per format. Compare that against the exports map and every path referenced there must appear in the list — a condition pointing at ./dist/index.d.cts while the tarball contains only index.d.ts is the most common broken-package shape on the registry, and it is invisible until a CommonJS consumer with moduleResolution: node16 tries to import it.
Automating the cross-check is a dozen lines of Node.js and removes the discipline problem entirely:
// scripts/verify-tarball.ts — run after `npm pack`
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const tarball = execSync("npm pack --dry-run --json", { encoding: "utf8" });
const shipped = new Set<string>(
JSON.parse(tarball)[0].files.map((f: { path: string }) => f.path)
);
const referenced: string[] = [];
const walk = (node: unknown): void => {
if (typeof node === "string") { referenced.push(node.replace(/^\.\//, "")); return; }
if (node && typeof node === "object") Object.values(node).forEach(walk);
};
walk(pkg.exports);
const missing = referenced.filter((p) => !shipped.has(p));
if (missing.length) {
console.error("exports references files that will not ship:\n " + missing.join("\n "));
process.exit(1);
}
console.log(`OK: all ${referenced.length} exported paths are present in the tarball`);
HAZARD PREVENTION
Symptom: The tarball is several megabytes and contains
src/,tests/, and a.env.example, even though.npmignorelists all three.Root cause: The manifest also has a
filesarray. Whenfilesis present it takes precedence, and.npmignoreonly filters within the directoriesfilesalready includes — so afiles: ["dist", "src"]entry re-adds a directory the ignore file was trying to exclude.Fix: Pick one mechanism. Prefer an explicit
filesallowlist (["dist"]) and delete.npmignoreentirely, so there is exactly one place that decides what ships. The full precedence rules are covered in The files Field and Controlling npm pack.
Making Validation Impossible to Skip
Validation that lives in a README instruction gets skipped under deadline pressure. Two npm lifecycle hooks make it structural instead. prepack runs before the tarball is assembled — the right place for the build, so a stale dist/ can never be published. prepublishOnly runs before an actual publish but not before a plain npm pack, which makes it the right place for the slow checks:
{
"scripts": {
"build": "tsup",
"prepack": "npm run build",
"prepublishOnly": "npm run check",
"check": "tsc --noEmit && publint && attw --pack ."
}
}
With that wiring, npm publish cannot succeed unless the source type-checks, the manifest is structurally valid, and the packed types resolve for every consumer mode — and the artefacts being validated were rebuilt moments earlier from current source. Mirroring the same npm run check invocation in CI keeps local and pipeline behaviour identical, so a green local run means a green pipeline rather than a different set of assumptions.
Rehearsing a Real Consumer Install
Every validator above reasons about the package statically. The last check before publishing should do the opposite: install the tarball into a throwaway project and import it the way a consumer would, in both module formats. This catches the residue that static analysis structurally cannot see — a runtime dependency that was declared as a dev dependency, a top-level await that breaks the CommonJS build, a native binding that is absent from the file list, a postinstall script that fails outside your repository.
The rehearsal is short enough to run on every release:
set -euo pipefail
TARBALL=$(npm pack --silent) # produces scope-lib-1.4.0.tgz
SANDBOX=$(mktemp -d)
mv "$TARBALL" "$SANDBOX/"
cd "$SANDBOX"
npm init -y > /dev/null
npm install "./$TARBALL" --silent # installs exactly what consumers get
# ESM consumer
node --input-type=module -e "
import { createClient } from '@scope/my-library';
console.log('esm ok:', typeof createClient);
"
# CommonJS consumer
node --input-type=commonjs -e "
const { createClient } = require('@scope/my-library');
console.log('cjs ok:', typeof createClient);
"
esm ok: function
cjs ok: function
Two lines of output, and they rule out an entire category of post-publish incident reports. If the package is ESM-only by design, the CommonJS probe should fail with ERR_REQUIRE_ESM — expect that failure explicitly rather than deleting the check, so a later change that silently reintroduces a CommonJS entry point is noticed.
A type-level rehearsal is worth the same twenty seconds. Writing a two-line .ts file in the sandbox and running tsc against it exercises the declaration files through a real resolution pass, under whichever moduleResolution setting your consumers actually use:
cat > probe.ts <<'TS'
import { createClient } from "@scope/my-library";
const client: ReturnType<typeof createClient> = createClient({ retries: 2 });
TS
npx tsc --noEmit --strict --module nodenext --moduleResolution nodenext probe.ts
A clean exit means a consumer on nodenext gets working types from the packed artefacts, not from your source tree. Repeat with --moduleResolution bundler if you support bundler consumers; the two modes disagree often enough that passing one proves little about the other, as the comparison in moduleResolution: bundler vs nodenext sets out.
The sandbox also answers a question none of the static tools address: how large is the install, really? du -sh node_modules inside the throwaway project reports the on-disk cost of your package plus everything it drags in, which is the number a consumer feels. A package whose own tarball is 15 kB but whose install footprint is 40 MB has a dependency problem, not a packaging problem — and the sandbox is where that becomes obvious rather than theoretical.
Clean up the sandbox on exit (trap 'rm -rf "$SANDBOX"' EXIT), and keep the whole rehearsal in a committed script rather than a wiki page, so the pipeline and every maintainer run the identical sequence. Treat a failure in the rehearsal as a release blocker rather than a warning: by the time the tarball exists, every cheaper check has already passed, so anything the sandbox surfaces is a genuine consumer-facing defect that no amount of static analysis was ever going to find.
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.
-
Dry-Running npm publish with Pack and Tarball Diffs — seeing what a release changes before it ships, against the version consumers already have.
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.