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)

Guides in This Section



Back to CI/CD, Publishing & npm Provenance