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

attw resolution modes compared Two-column comparison showing that node10 mode ignores the exports field and falls back to the top-level types field, while node16 and bundler modes read the types condition inside exports for both CJS and ESM entry points. node10 mode node16 / bundler mode ignores exports field reads top-level types field or typesVersions mapping used by moduleResolution: node reads exports conditions types condition checked first separate cjs/esm declarations used by node16, nodenext, bundler

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/require conditions are correct.

Root cause: The types condition points at a .d.ts file, and TypeScript infers a declaration file’s module system from its extension, not from which condition branch it’s nested under. A plain .d.ts is interpreted using the package’s type field, which can disagree with the specific condition it’s attached to.

Fix: Use explicit .d.mts for the import types branch and .d.cts for the require types 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-rules should be documented, not silent. If you intentionally drop node10 support, use --ignore-rules=no-resolution in 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-esm findings 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 plain node_modules.
  • Bundler mode is the most permissive. bundler resolution (used by Vite, esbuild consumers) tolerates some structural issues that node16 treats as hard errors — a clean bundler result does not guarantee node16 consumers 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.



↑ Back to Validating Packages Before Publish