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

Pre-publish validation pipeline A four-stage pipeline showing the build step feeding into publint, then are-the-types-wrong, then an npm pack dry run, culminating in a gated publish command. Pre-Publish Validation Chain Build emit dist/ publint exports map attw --pack type resolution Gated publish npm publish

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: publint passes locally but fails in CI with Cannot 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 build immediately before publint in the same job step, and add clean: true to 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 files field omitted the dist/esm subdirectory, or a stray .npmignore rule (often inherited from a .gitignore copy) excluded built output.

Fix: Treat npm pack --dry-run as 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 four validation layers Four stacked layers of package validation. tsc --noEmit validates source types. publint validates the package.json manifest and file existence. arethetypeswrong validates how each consumer mode resolves types. npm pack validates the actual file set that ships in the tarball. Each validator sees a different layer of the package tsc --noEmit layer: your source Catches type errors before they are baked into .d.ts output. Blind to everything about packaging. publint layer: the manifest Condition order, file existence, main/module/exports agreement. Blind to whether the types are semantically right. attw layer: consumer view Resolves the package as node10, node16 and bundler consumers do. Blind to files excluded from the tarball. npm pack layer: the tarball The literal file set consumers download — the last word on what shipped. Blind to correctness; it only reports contents.

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.

From working directory to published tarball The working directory passes through the files array and ignore rules to produce the tarball contents. Every path referenced by the exports map must be present in that final file set, which is what npm pack dry-run reports. What survives the trip to the registry working directory src/ dist/ tests/ .env everything on disk files + ignore rules files array wins over .npmignore README, LICENSE always kept tarball contents what consumers download npm pack --dry-run the cross-check that catches broken packages every path in exports must appear in the tarball list and every declaration file must have a matching runtime file

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 .npmignore lists all three.

Root cause: The manifest also has a files array. When files is present it takes precedence, and .npmignore only filters within the directories files already includes — so a files: ["dist", "src"] entry re-adds a directory the ignore file was trying to exclude.

Fix: Pick one mechanism. Prefer an explicit files allowlist (["dist"]) and delete .npmignore entirely, 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



Back to CI/CD, Publishing & npm Provenance