Checking Published Types with Are the Types Wrong
Are the Types Wrong (attw) answers one question for every entry point of your package: when a consumer imports it under a given TypeScript module resolution mode, does TypeScript find declarations, and do those declarations describe the same module that the runtime will load? A package can have perfect types under bundler resolution and none at all under node16, or types that claim ESM while Node.js loads CommonJS. attw checks every combination in seconds from a packed tarball. This guide explains its resolution matrix, every problem code you are likely to see, and how to use it as a release gate.
Exact output and what it means
Run it against the packed package:
$ npx @arethetypeswrong/cli --pack .
@acme/client v3.0.0
Build tools:
- typescript@5.7.2
- tsup@8.3.5
❌ No types
🎭 Import resolved to a CommonJS type declaration file, but an ESM JavaScript file.
🥴 Import found in a type declaration file failed to resolve.
┌───────────────────┬────────────────────┬─────────────────────────┐
│ │ "@acme/client" │ "@acme/client/node" │
├───────────────────┼────────────────────┼─────────────────────────┤
│ node10 │ 🟢 │ ❌ No types │
├───────────────────┼────────────────────┼─────────────────────────┤
│ node16 (from CJS) │ 🟢 (CJS) │ 🟢 (CJS) │
├───────────────────┼────────────────────┼─────────────────────────┤
│ node16 (from ESM) │ 🎭 Masquerading as CJS │ 🥴 Internal resolution error │
├───────────────────┼────────────────────┼─────────────────────────┤
│ bundler │ 🟢 │ 🟢 │
└───────────────────┴────────────────────┴─────────────────────────┘
Columns are your entry points (from exports); rows are the four resolution scenarios TypeScript users can be in. A green cell means TypeScript resolves a declaration that matches the JavaScript the runtime loads. Everything else is a problem some consumer will hit.
Why four resolution scenarios
TypeScript's resolution modes treat packages differently, and node16 behaves differently depending on whether the importing file is ESM or CommonJS. attw simulates each:
The mechanics behind each row are covered in TypeScript Declaration Publishing. The practical point is that a package tested only in a Vite application exercises the bundler row and nothing else.
The problem codes and their fixes
❌ No types — TypeScript found the JavaScript but no declaration. Add a types condition to the entry, first in its object, as shown in Fixing Types Not Found Under node16 Module Resolution. In the node10 row, subpaths need typesVersions.
💀 Resolution failed — the entry cannot be resolved at all in this mode, usually a subpath under node10, which ignores exports.
🎭 Masquerading as CJS / ❗️ Masquerading as ESM — the declaration's module format differs from the JavaScript file's. Ship one declaration per format; see Fixing 'Masquerading as CJS' Type Errors.
🥴 Internal resolution error — a declaration file imports another declaration file with a path that does not resolve under this mode, typically an extensionless relative import in a .d.ts used under node16. Emit declarations with nodenext settings so imports carry .js extensions, or bundle declarations into one file per entry.
🤨 CJS default export — the CommonJS declaration uses export default, but the JavaScript sets module.exports to the value. Consumers get types for .default that does not exist at runtime, or the reverse. Make the declaration use export =, or make the runtime export exports.default and document it.
⚠️ ESM (dynamic import only) — informational for node16 from CJS: the entry is ESM-only, so CommonJS consumers can load it only with import() (or require(esm) on newer runtimes).
Using attw in CI
# Fail on any problem except those in ignored resolution modes
npx @arethetypeswrong/cli --pack . --profile node16
# Check only specific entry points
npx @arethetypeswrong/cli --pack . --entrypoints . ./node
# Ignore a specific rule you have consciously accepted
npx @arethetypeswrong/cli --pack . --ignore-rules cjs-resolves-to-esm
# Machine-readable output for custom reporting
npx @arethetypeswrong/cli --pack . --format json > attw.json
Profiles set which rows count: strict (all four), node16 (ignores node10) and esm-only (ignores CommonJS rows, for packages that deliberately ship no CommonJS). Pick the profile that matches the consumers you support and make any failure block the release.
A workflow step:
- run: pnpm run build
- name: Verify published types
run: pnpm exec attw --pack . --profile node16
In a monorepo, run it per publishable package — for example, pnpm -r --filter "./packages/**" exec attw --pack . --profile node16 — or as a cached task that depends on build.
Deciding which rows you support
The profile you choose is a support policy, so make the decision deliberately rather than by whatever currently passes.
Support node10 if a meaningful share of your consumers compile TypeScript with module: commonjs and no explicit moduleResolution — common in older back-end services — and your package still ships CommonJS. Supporting it costs a typesVersions block for subpaths, which can be generated from exports.
Support node16 (from CJS) if you ship a CommonJS build or expect CommonJS consumers to load your ESM build through require(esm). This row is where format mismatches between .d.ts and .d.cts show up.
Always support node16 (from ESM) and bundler. These are the modes used by modern Node.js projects and by every bundled application; a red cell there affects the majority of TypeScript users.
Write the policy in the README ("types are tested for node16, nodenext and bundler resolution") and mirror it in the CI profile. When a consumer reports a problem in a mode you do not support, the README answers the question, and the CI configuration shows the decision was intentional.
Checking what is already on the registry
attw can analyse any published version, not just your working tree: npx @arethetypeswrong/cli @acme/client@3.0.0 downloads and checks that exact release. Two uses make this valuable. Before changing your build, run it against the current release to record a baseline — the table becomes the "before" picture for your pull request. And when a consumer reports a type problem against an older version, check that version directly to see whether the problem was already present or was introduced later, which tells you whether a patch release on an older line is needed.
Worked example: a new subpath that only worked in bundlers
A team adds @acme/client/node for Node-specific helpers. The exports entry has import and require targets and a single types pointing at dist/node.d.ts, which contains export * from './shared' — no extension. Their applications, all bundled, work fine. attw's node16 (from ESM) row shows 🥴 Internal resolution error for the new column: under node16, ./shared must be ./shared.js. Switching the declaration build to moduleResolution: nodenext makes TypeScript emit ./shared.js, and splitting declarations into .d.ts and .d.cts fixes the format mismatch that attw reports next. The whole fix happens before the first consumer on a Node.js service tries the new subpath.
Edge cases worth knowing
attw analyses the package in isolation, so a few situations need interpretation rather than a blind fix. Packages with peer-dependency types — a React component library whose declarations import from react — are resolved without those peers installed; attw reports what it can, and missing peer types are expected rather than a defect. Packages that intentionally support only some consumers should encode that in the profile rather than chasing green cells they do not need: an ESM-only package gains nothing from adding CommonJS types just to satisfy the node16 (from CJS) row. And wildcard subpath patterns ("./icons/*") are checked against the files that match, so an empty or missing folder in the tarball can make a pattern appear fine while every real import fails; pair attw with the pack inspection that asserts those files exist.
Prevention and CI/CD guardrails
- Run attw on every pull request that touches a published package's build or manifest.
- Choose a profile explicitly and document it in the README as your supported TypeScript configurations.
- Never ignore a rule without a written reason in the CI config.
- Keep one fixture per supported resolution mode as a second line of defence behind attw.
Frequently Asked Questions
Does attw require publishing first?
No. --pack . packs the current directory into a temporary tarball and analyses it. You can also pass an existing .tgz file, or a package name and version to check what is already on the registry.
Why does my package pass in the web version but fail in CI? The web version analyses the published package on the registry; CI analyses your current build. A difference means your working tree differs from the last release — which is usually the point of running it in CI.
Is a green attw table enough to trust my types? It proves types resolve and match module formats. It does not prove the declarations are accurate for your runtime behaviour; type-level tests and fixture consumers cover that.
Can attw check packages in a private registry?
Yes. Pack locally with --pack ., or point it at a local tarball downloaded with npm pack @acme/client@3.0.0 using your registry credentials. The analysis itself never needs network access to your registry.
Related
- Testing and Validating Packages Before Publishing puts attw into the full validation stack.
- Validating Package Exports with publint covers the manifest checks that complement attw.
- Generating Dual CJS/ESM Type Definitions produces the per-format declarations attw expects.
- Configuring typesVersions for Older TypeScript Consumers fixes the
node10row.