Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

Generating Dual CJS/ESM Type Definitions

A dual package ships both import and require entry points, and under modern resolution each entry needs its own declaration file: .d.mts for ESM and .d.cts for CJS. Reusing a single index.d.ts for both works under bundlers but breaks under Node's node16 resolver. This page shows how to produce both declaration formats and wire them into conditional exports correctly.

Why a Single .d.ts Breaks node16 Resolution

When a consumer sets moduleResolution: "node16" (or "nodenext"), TypeScript treats a file's module format as determined by its extension, exactly like Node does at runtime. A .d.mts is ESM; a .d.cts is CJS; a plain .d.ts inherits its format from the nearest package.json "type". If your ESM consumer is handed a .d.ts that describes a CommonJS export = shape, the compiler reports a mismatch — the "masquerading" failure class from TypeScript Declaration Publishing.

Concretely: a CJS build typically compiles to module.exports = ... (described by export = in a .d.cts), while an ESM build uses named/export default (described in a .d.mts). One declaration file cannot honestly describe both. So each runtime condition must point at a format-matched declaration. This mirrors the runtime split covered in ESM and CJS Interoperability.

Dual declaration flow One source produces two builds, each emitting a format-specific declaration that maps to its exports condition. src/index.ts single source ESM build index.mjs + d.mts CJS build index.cjs + d.cts import condition types: ./index.d.mts require condition types: ./index.d.cts
One source, two format-matched declarations, each routed to the exports condition that consumes it.

Step-by-Step

1. Emit format-specific declarations

The fastest route is tsup's dts: true, which emits a declaration per format alongside the JS:

// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,        // -> index.d.mts (esm) and index.d.cts (cjs)
  clean: true,
  outDir: 'dist',
});

If you build JS with another tool and need only declarations, run two tsc passes — one per module setting — and rename the output. tsc keys the declaration extension off module, so an ESM pass yields .d.ts you rename to .d.mts, and a CJS pass yields one you rename to .d.cts:

# ESM declarations
tsc -p tsconfig.json --module NodeNext --emitDeclarationOnly --outDir dist/esm
mv dist/esm/index.d.ts dist/index.d.mts

# CJS declarations
tsc -p tsconfig.json --module CommonJS --moduleResolution Node10 --emitDeclarationOnly --outDir dist/cjs
mv dist/cjs/index.d.ts dist/index.d.cts

A cleaner alternative to renaming: create two tiny tsconfig files whose nearest package.json "type" differs, or use sub-folders each containing a package.json with { "type": "module" } / { "type": "commonjs" }, so tsc emits the correct extension natively.

2. Wire the declarations into conditional exports

Each runtime condition gets a nested types that points at the format-matched declaration. The types key must come first within each condition object, before default:

{
  "name": "@scope/dual",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "types": "./dist/index.d.cts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

The top-level types is a fallback for legacy moduleResolution: "node" consumers, who ignore exports. Point it at the CJS declaration since legacy resolvers assume CJS. The mechanics of these fields are detailed in Understanding package.json Fields.

3. List both files in the published tarball

Make sure both declarations are actually packed:

{
  "files": ["dist"]
}

4. Sub-folder package.json trick (no renaming)

The renaming approach in step 1 is brittle because a mv step is easy to forget. A more robust pattern lets tsc choose the right extension itself by placing a tiny package.json in each output folder that sets the module type. Node and TypeScript both honor the nearest package.json "type" when deciding a .d.ts file's format, so the declaration emitted into a "type": "module" folder behaves as ESM and the one in a "type": "commonjs" folder behaves as CJS — without changing the .d.ts extension at all:

mkdir -p dist/esm dist/cjs
echo '{"type":"module"}'     > dist/esm/package.json
echo '{"type":"commonjs"}'   > dist/cjs/package.json

Your exports then points at ./dist/esm/index.d.ts and ./dist/cjs/index.d.ts, each interpreted correctly because of its sibling package.json. This avoids the rename step entirely and is the pattern many published libraries adopt. The trade-off is that .d.mts/.d.cts extensions are more self-documenting in the published tarball, so choose based on whether you prefer explicit extensions or folder-scoped types.

Configuration Notes

  • declaration + declarationMap belong in your shared compiler config; the per-pass --module override is all that changes between ESM and CJS declaration emits.
  • Set verbatimModuleSyntax: true so tsc does not rewrite import/export syntax in ways that desync the declaration from the emitted JS.
  • A .d.cts describing module.exports = ... should use export = Foo; so require() consumers see the value directly; a .d.mts uses export default / named exports.
  • Keep entry filenames distinct (index.mjs/index.cjs) so the declarations sit next to their JS with matching base names.

Validation

Confirm both declarations exist and that resolution succeeds under each mode:

# 1. Both format-specific declarations were emitted
ls dist/index.d.mts dist/index.d.cts

# 2. Project type-checks
npx tsc --noEmit -p tsconfig.json

# 3. Pack and probe every consumer resolution mode
npm pack
npx --yes @arethetypeswrong/cli ./*.tgz --format table

A green node16 (from ESM) row confirms ESM consumers resolve .d.mts; a green node16 (from CJS) row confirms CJS consumers resolve .d.cts. Red rows here are the same family as Fixing 'Cannot Find Module' Type Declaration Errors.

Guardrails

  • Run the @arethetypeswrong/cli --pack check in CI before publish; fail the build on any red mode.
  • Add a consumer smoke test that imports under moduleResolution: "NodeNext" from both an ESM and a CJS test file.
  • Assert both dist/index.d.mts and dist/index.d.cts exist as a post-build step so a dropped declaration fails fast.
  • Verify types precedes default in every exports condition during code review.
  • Pin typescript and tsup versions so emit behavior is reproducible across machines.

Frequently Asked Questions

Can I just rename my index.d.ts to both .d.mts and .d.cts? Only if both formats genuinely have the same exported shape, which is rare. A CJS build that compiles to module.exports = ... needs export = in its .d.cts, while the ESM build uses export default/named exports in its .d.mts. Renaming a single file usually produces a masquerading error for one of the two consumers.

Why does my CJS declaration need export = instead of export default? When a CJS build emits module.exports = Foo, a require() consumer receives Foo directly. The matching declaration is export = Foo;. Using export default describes a { default: Foo } shape that does not match the runtime, breaking type-checking for CJS consumers.

Do I need dual declarations if all my consumers use a bundler? No. moduleResolution: "bundler" does not distinguish .d.mts from .d.cts and is lenient about it. But publishing dual declarations costs little and makes your package correct for node16 consumers too, so it is the safer default for a published library.

tsup emits .d.ts instead of .d.mts/.d.cts — what is wrong? tsup only emits format-specific declaration extensions when both format: ['esm','cjs'] and dts: true are set and it can infer the package type. Confirm both formats are listed; if you build a single format, you get a single .d.ts.

Related

TypeScript Declaration Publishing