Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Fixing 'Masquerading as CJS' Type Errors

"Masquerading as CJS" is the label Are the Types Wrong gives to a package whose runtime entry for import is an ES module, but whose declaration file tells TypeScript it is CommonJS. Consumers see confusing errors — default imports that need .default, esModuleInterop warnings, or types that compile but crash at runtime — and the package looks broken only from certain project configurations. The sibling problem, "Masquerading as ESM", is the mirror image. This guide explains how TypeScript decides a declaration's module format, why a single index.d.ts causes the mismatch, and how to ship declarations that tell the truth.

Exact symptoms and error messages

The attw report prints the problem per resolver:

$ npx @arethetypeswrong/cli --pack .

your-lib v3.1.0

 ❗️ Import resolved to an ESM type declaration file, but a CommonJS JavaScript file. https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseESM.md
 🎭 Import resolved to a CommonJS type declaration file, but an ESM JavaScript file. https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md

┌───────────────────┬──────────────────────────────┐
│                   │ "your-lib"                   │
├───────────────────┼──────────────────────────────┤
│ node10            │ 🟢                           │
├───────────────────┼──────────────────────────────┤
│ node16 (from CJS) │ ❗️ Masquerading as ESM        │
├───────────────────┼──────────────────────────────┤
│ node16 (from ESM) │ 🎭 Masquerading as CJS        │
├───────────────────┼──────────────────────────────┤
│ bundler           │ 🟢                           │
└───────────────────┴──────────────────────────────┘

In a consumer's ESM project, a default import compiles but is typed wrongly:

error TS2349: This expression is not callable.
  Type 'typeof import("/app/node_modules/your-lib/dist/index")' has no call signatures.

or TypeScript demands .default access that fails at runtime with TypeError: yourLib.default is not a function.

Root cause analysis

TypeScript decides whether a declaration file describes ESM or CommonJS from its own extension and the nearest package.json type field — exactly as Node.js decides for JavaScript files. .d.mts is always ESM, .d.cts is always CommonJS, and .d.ts follows type: ESM if "type": "module", CommonJS otherwise. The broader rules are covered in TypeScript Declaration Publishing.

How TypeScript infers a declaration file's module format A matrix of declaration file extensions against the package type field showing whether TypeScript treats each as ESM or CommonJS. package has no type / commonjs package has type: module .d.ts CommonJS ESM .d.cts CommonJS CommonJS .d.mts ESM ESM
A .d.ts file can only describe one format — the one implied by the package's type field.

A dual package usually ships two JavaScript files — index.mjs (ESM) and index.js (CommonJS) in a CommonJS-typed package — but only one index.d.ts. Both exports conditions point their types at that single file. Because the package has no type field, TypeScript reads index.d.ts as CommonJS. When an ESM consumer resolves the import condition, the runtime file is ESM but the declaration claims CommonJS: masquerading as CJS. The mirror case happens in a "type": "module" package with one .d.ts used for the require condition.

Why does it matter if the shapes are identical? Because default exports and interop differ between the formats. A CommonJS declaration with export default is interpreted as module.exports.default, while an ES module's default export is the module's default binding. Under node16 rules, TypeScript models the ESM-importing-CJS interop precisely, and a wrong format produces wrong types for the default import — exactly the errors above.

Resolution and configuration patch

Ship one declaration file per format, with the extension that states its format, and point each condition at the right one.

One shared declaration versus one per format Left panel shows both conditions pointing types at a single index.d.ts; right panel points import at index.d.mts and require at index.d.ts in a CommonJS-typed package. Masquerading // no "type" field -> .d.ts is CJS "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } } Truthful "exports": { ".": { "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, "require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }
Each runtime file gets a declaration whose extension states the same module format.

Implementation steps:

  1. Decide the package's type. For a modern package, "type": "module" is the usual choice: then index.js + index.d.ts describe ESM, and index.cjs + index.d.cts describe CommonJS.
  2. Emit both declaration flavours. tsup does this with format: ['esm', 'cjs'] and dts: true. With tsc, emit declarations once, then copy index.d.ts to index.d.cts (or .d.mts) and fix relative import extensions inside the copy (./client.js becomes ./client.cjs).
  3. Nest types under each format condition, as in the right-hand panel.
  4. Check the default export shape. If your CommonJS build assigns module.exports = fn, the CommonJS declaration must say export = fn, not export default fn. tsup and many bundlers emit module.exports.default = fn for a default export, which matches export default — but then CommonJS consumers must write require('your-lib').default. Decide which shape you want and make both the build and the declaration agree.
  5. Re-run Are the Types Wrong until every resolver row is green.

Worked example: a default-export utility

A small package exports a single function as its default: export default function slugify(...). It is built with a bundler into index.mjs and index.js (CommonJS, module.exports = slugify via the bundler's cjsInterop option), with one index.d.ts containing export default function slugify(...). ESM consumers on node16 get TS2349: This expression is not callable, because TypeScript treats index.d.ts as CommonJS with module.exports.default, and an ESM default import of that is the whole module.exports object.

The fix emits index.d.mts with export default function slugify(...) for ESM, and index.d.ts with declare function slugify(...): string; export = slugify; for CommonJS, matching the runtime shape. attw goes green for every resolver, and both import slugify from 'your-lib' and const slugify = require('your-lib') type-check and run.

How the wrong declaration misleads an ESM consumer An ESM consumer imports the package; TypeScript resolves a CommonJS-flavoured declaration while Node.js loads the ESM runtime file, producing mismatched default export types. consumer (ESM) TypeScript Node.js import slugify from 'your-lib' index.d.ts read as CJS: default = module.exports run the compiled code index.mjs: default = slugify
TypeScript and Node.js resolve the same import to files that disagree about the module format.

Relative imports inside copied declarations

The simplest way to produce the second declaration flavour is to copy the first and rename it, but declaration files import each other, and those imports carry extensions under node16 rules. A dist/index.d.ts that says export { Client } from './client.js' works for the ESM flavour. Copied to dist/index.d.cts, the same line now tells TypeScript to find client.js and interpret it through the package's type field — ESM again — which reintroduces the masquerade one level down. The copy must also rewrite ./client.js to ./client.cjs and ship client.d.cts alongside.

Three ways to avoid hand-maintaining this:

  • Bundle declarations per format, so each flavour is a single self-contained file with no relative imports. tsup's dts option and API Extractor both do this.
  • Emit twice with tsc into separate folders, with a one-line package.json containing {"type": "commonjs"} inside the CommonJS output folder. Both the .js and .d.ts files in that folder are then read as CommonJS, and relative imports need no rewriting.
  • Use a dedicated tool such as tsup, tshy or zshy, which are designed around producing correct dual output, including declaration flavours and exports maps.

Whichever you pick, the check is the same: attw green for every resolver, from both ESM and CommonJS importers.

The ESM-only escape hatch

If maintaining two flavours is more trouble than it is worth, consider dropping CommonJS. With require(esm) available in Node.js 20.19 and 22.12 onwards, CommonJS consumers on supported runtimes can load an ESM-only package directly, and your package needs exactly one runtime file and one declaration file per entry point. There is nothing left to masquerade. The cost is losing consumers on older runtimes and tools with their own module loaders; for many libraries that audience is now small enough to accept with a major version bump.

Why bundler resolution often hides it

Consumers using moduleResolution: "bundler" usually do not see the problem, because that mode applies more lenient interop rules and assumes a bundler will smooth over the format difference. That is why maintainers testing only inside Vite or Next.js applications never reproduce the bug. The reports come from Node.js servers, CLIs and libraries compiled with nodenext, which follow Node's real interop. Test your package under node16 from both an ESM and a CommonJS fixture — or let attw do it — before assuming it works.

CLI validation and debug commands

# Full matrix, including ESM and CJS importers under node16
npx @arethetypeswrong/cli --pack .

# Only the entry points you care about, failing CI on any problem
npx @arethetypeswrong/cli --pack . --entrypoints . ./react

# Confirm which declaration TypeScript picked and in what format
npx tsc --noEmit --module nodenext --moduleResolution nodenext --traceResolution esm-fixture.mts \
  | grep -E "Resolving module 'your-lib'|resolved to|ESM|CommonJS" | head

Prevention and CI/CD guardrails

  • Run attw in CI on every release, with the resolvers you support.
  • Never point both format conditions at one .d.ts.
  • Make the default export shape a deliberate decision and document it in the README for CommonJS consumers.
  • Prefer ESM-only where your Node.js floor allows it — one format, one declaration, no masquerading, as discussed in Loading ESM from CommonJS with require(esm).

Frequently Asked Questions

Is Masquerading as CJS a TypeScript bug? No. TypeScript is reading the declaration exactly as its extension and the type field say. The package tells it the wrong format.

Do I need separate declarations if my API has no default export? Named-export-only packages are less affected, because named exports interoperate similarly in both directions, but attw still flags the mismatch and some edge cases (such as import * as ns shapes) differ. Shipping both flavours is cheap and removes the doubt.

What does Masquerading as ESM mean? The reverse: the declaration claims ESM while the require condition loads a CommonJS file. CommonJS consumers under node16 then get errors such as TS1479 about importing an ES module with require.

Will fixing the declarations break existing consumers? It can change what TypeScript reports for consumers who wrote workarounds such as yourLib.default(...) to satisfy the old, wrong types. The runtime does not change, so the new errors point at code that was already fragile. Ship the fix in a minor release with a changelog note explaining the corrected default export shape.

How do I test the fix without publishing? Pack the package, install the tarball into two fixtures — one with "type": "module" and one without — both compiled with module and moduleResolution set to nodenext, and import the package the way your README documents. Both fixtures must type-check and run.

Related

TypeScript Declaration Publishing