Checking Types with are-the-types-wrong
Diagnose broken TypeScript resolution in published packages with attw (are-the-types-wrong): masquerading types, missing conditions, and node10 fallbacks.
A package can compile cleanly, pass publint, and still hand every TypeScript consumer broken types — because the file that runtime resolution points to and the file TypeScript’s type resolution points to can silently diverge. A common symptom looks like this in a consumer’s editor:
Could not find a declaration file for module '@acme/sdk'. 'node_modules/@acme/sdk/dist/index.mjs'
implicitly has an 'any' type.
or, more subtly, an import that resolves to a type file describing the wrong module format entirely. are-the-types-wrong (attw) exists specifically to catch this class of bug, by simulating TypeScript’s resolver across every moduleResolution mode a real consumer might use, as part of the broader pre-publish validation workflow alongside publint.
Root Cause
TypeScript resolves runtime code and type declarations through two related but distinct paths. In a well-formed exports map, the types condition points at the correct .d.ts/.d.mts/.d.cts file for each format. When the build tooling generates only one flavor of declaration file, or the .d.ts extension is used for a file that TypeScript needs to treat as ESM, the resolver locates a file — so no error occurs at build or publish time — but it’s the wrong file for that consumer’s module system. attw catches this by literally running TypeScript’s resolution algorithm against your packed tarball under node10, node16 (cjs), node16 (esm), and bundler modes simultaneously, rather than trusting that a .d.ts file existing is the same as it being correct.
Minimal Reproduction
This package looks correct — every path in exports resolves to a real file — but only ships one shared declaration file for both formats:
{
"name": "sketchy-types",
"version": "1.0.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
}
// dist/index.d.ts — written as if it only describes the CJS build
export = SdkNamespace;
declare namespace SdkNamespace {
function doThing(): void;
}
A CJS consumer using require() gets correct types. An ESM consumer using import { doThing } from 'sketchy-types' gets a TypeScript error, because export = syntax is a CommonJS-only construct — the .d.ts file describes the wrong module shape for the import condition it’s attached to.
$ npx attw --pack .
┌─────────────────┬──────────────┬──────────────┬──────────────┐
│ │ node10 │ node16 (cjs) │ node16 (esm) │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ . │ ✓ Resolved │ ✓ Resolved │ ✗ Masquerading as CJS │
└─────────────────┴──────────────┴──────────────┴──────────────┘
How attw Simulates Resolution
Step-by-Step Fix
Step 1 — Run attw against the packed tarball
npx attw --pack .
Always use --pack; running attw against your working directory instead of a real tarball can hide files field omissions that only manifest post-publish.
Step 2 — Split the declaration file by module format
Generate distinct .d.mts and .d.cts files instead of one shared .d.ts. With a modern bundler this is usually a single config flag:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true, // emits index.d.mts and index.d.cts automatically for dual formats
});
Step 3 — Point the exports map at the correct declaration per condition
"exports": {
".": {
- "types": "./dist/index.d.ts",
+ "types": {
+ "import": "./dist/index.d.mts",
+ "require": "./dist/index.d.cts"
+ },
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
HAZARD PREVENTION
Symptom: attw reports “Masquerading as CJS” (or “Masquerading as ESM”) even though the runtime
import/requireconditions are correct.Root cause: The
typescondition points at a.d.tsfile, and TypeScript infers a declaration file’s module system from its extension, not from which condition branch it’s nested under. A plain.d.tsis interpreted using the package’stypefield, which can disagree with the specific condition it’s attached to.Fix: Use explicit
.d.mtsfor theimporttypes branch and.d.ctsfor therequiretypes branch, as shown above. This removes any ambiguity about which module syntax the declaration file uses.
Step 4 — Add a node10 fallback for classic resolution
Consumers still on moduleResolution: "node" (TypeScript’s pre-4.7 default, still common in unmigrated projects) never read exports at all. Add a top-level types field as a fallback:
{
"name": "sketchy-types",
"version": "1.0.1",
+ "types": "./dist/index.d.cts",
"exports": { "...": "..." }
}
Verification
npm run build
npx attw --pack .
Expected output once every condition resolves correctly:
┌─────────────────┬──────────────┬──────────────┬──────────────┐
│ │ node10 │ node16 (cjs) │ node16 (esm) │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ . │ ✓ Resolved │ ✓ Resolved │ ✓ Resolved │
└─────────────────┴──────────────┴──────────────┴──────────────┘
No problems found
Edge Cases / Gotchas
--ignore-rulesshould be documented, not silent. If you intentionally dropnode10support, use--ignore-rules=no-resolutionin CI and note the minimum TypeScript version in your README rather than leaving the reason implicit.- Re-exported types from a dependency. If your package re-exports types from another package, attw can report
false-cjs/false-esmfindings that originate in the dependency, not your own build — check the dependency’s own attw report before assuming your exports map is at fault. - Yarn PnP and pnpm. Run
attw --pack .in both linker modes if you support them; the packed tarball is identical, but consumers under Yarn PnP resolve through a virtual filesystem that occasionally surfaces resolution differences invisible under plainnode_modules. - Bundler mode is the most permissive.
bundlerresolution (used by Vite, esbuild consumers) tolerates some structural issues thatnode16treats as hard errors — a cleanbundlerresult does not guaranteenode16consumers are safe.
Frequently Asked Questions
What does “masquerading as CJS” mean in attw output?
It means a declaration file resolved for an ESM entry point has a .d.ts extension instead of .d.mts, so TypeScript treats it as CommonJS syntax rules even though the runtime file is ESM. Rename the file to .d.mts and point the import condition’s types entry at it to fix the mismatch.
Why does attw report a problem only under node10 resolution?
node10 mode simulates TypeScript’s classic resolution, which ignores the exports field entirely and falls back to the top-level types field or a typesVersions mapping. If neither is present, node10 consumers get no types at all even though node16 and bundler modes resolve correctly through exports.
Can I ignore specific attw rules instead of fixing them?
Yes, with --ignore-rules, but treat this as a documented exception rather than a default. For example, --ignore-rules=cjs-resolves-to-esm is sometimes acceptable for packages that intentionally drop CJS support, as long as the README states the minimum Node.js version required.
Does attw check runtime behavior, or only type declarations?
Only type resolution. attw never executes your code; it simulates how TypeScript’s module resolver would locate and interpret .d.ts files for a given moduleResolution setting. Pair it with publint for file-existence checks and a real npm pack smoke test for runtime behavior.
Related
- Using publint to Catch Exports Errors — the complementary check that confirms every exports path resolves to a file that actually exists.
- Validating Packages Before Publish — the full validation chain this guide is one part of.
- Mastering the package.json Exports Field — the required condition order and nested
typesobject syntax that attw verifies.