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

Step-by-Step The fastest route is tsup's dts: true, which emits a declaration per format alongside the JS: Step-by-Step The fastest route is tsup's dts: true, which emits a declaration per format alongside the JS:
Step-by-Step — the core idea of this section at a glance.

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.
Configuration Notes Configuration Notes in production JavaScript package workflows. Configuration Notes Configuration Notes in production JavaScript package workflows.
Configuration Notes — the core idea of this section at a glance.

Validation

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

Validation Confirm both declarations exist and that resolution succeeds under each mode: Validation Confirm both declarations exist and that resolution succeeds under each mode:
Validation — the core idea of this section at a glance.
# 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.
Guardrails Guardrails in production JavaScript package workflows. Guardrails Guardrails in production JavaScript package workflows.
Guardrails — the core idea of this section at a glance.

Why one declaration file is not enough

Under node16/nodenext module resolution, the TypeScript checker follows the same conditional exports as the runtime, which is why a dual-format package needs two declaration files rather than one. When a consumer imports your package, the checker resolves the import branch's types; when they require it, the require branch's types. A single shared .d.ts pointed at from both branches means the require consumer gets ESM-shaped types — with export default and named exports that do not match how CommonJS actually exposes the module — so type-checking fails or produces wrong inferences even though the JavaScript resolves.

Types per condition import resolves .d.ts; require resolves .d.cts. import → .d.ts ESM-shaped types require → .d.cts CJS-shaped types one file fails wrong shape for require
Under node16 the checker follows exports, so each format needs its own declaration.

The require branch needs a CommonJS-flavored .d.cts that describes the module as CommonJS presents it, nested under its own types condition. Getting this right means emitting both flavors and wiring each into the correct branch, so tsc resolves the format-appropriate declaration for each import style. This is invisible in your own repository — your tests import the source directly and never resolve the published conditions — so the mismatch surfaces only for a consumer under strict resolution, which is why the dual-declaration setup must be verified against the packed tarball rather than trusted from a local build.

Verifying dual declarations with attw

The tool that makes dual declarations verifiable is @arethetypeswrong/cli, which packs your tarball and reports a grid of every module-and-resolution combination a consumer might use — node16 import, node16 require, bundler, legacy — flagging exactly which cell is wrong. A missing .d.cts, a reordered condition, or a require branch pointing at ESM-shaped types shows up as a specific failing cell rather than a vague error, so you know precisely what to fix.

attw verification Pack the tarball, check every resolution mode. attw --pack resolution grid failing cell names the problem assert .d.cts backstop
attw plus file assertions catch a wrong declaration before a consumer does.
- run: pnpm build
- run: pnpm exec attw --pack .
- run: test -f dist/index.d.ts && test -f dist/index.d.cts

Running attw in CI plus explicit file-existence assertions turns 'the types are wrong under require' from a consumer-reported bug into a red build on the pull request that introduced it. The file assertions are a cheap backstop against a silently-disabled declaration step, and the attw grid is the comprehensive check that every resolution mode finds a correct declaration. Together they enforce the dual-declaration contract against the artifact a consumer actually resolves, which is the only place the contract can be meaningfully checked — a source-importing test never exercises the published conditions where the dual-declaration requirement lives.

Generating the two flavors from one source

Producing both declaration flavors from a single TypeScript source is a matter of running the compiler (or a tool that wraps it) so that each format's declaration describes the module as that format presents it. The .d.ts for the ESM build uses export/export default matching the ESM output; the .d.cts for the CommonJS build describes the module.exports shape. Tools like tsup with dts: true emit both when configured for dual format, or a dedicated tsc pass per format produces them explicitly.

Emit + wire Emit both flavors, wire each into its condition. build dual format emit .d.ts + .d.cts wire per condition types nested validate tarball both correct
Emitting the declarations and wiring them into exports are two separate steps.
{
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  }
}

The key is that emitting the files and wiring them are two separate steps that both must be right: the build produces .d.ts and .d.cts, and the exports map nests each under the matching condition's types. A package that emits both but wires only one leaves a format's consumers with wrong types despite the files existing. This is why validating against the packed tarball is essential — it confirms both that the declarations were emitted and that every resolution mode resolves the correct one, catching the common mistake of correct files wired incorrectly.

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.

Why do I need both .d.ts and .d.cts?

Under node16/nodenext resolution the checker follows conditional exports, so the require branch needs a CommonJS-flavored .d.cts describing the module as CommonJS presents it. A single shared .d.ts gives require consumers ESM-shaped types that don't match, so type-checking fails even when the JavaScript resolves.

How do I verify my dual declarations are correct?

Run @arethetypeswrong/cli --pack in CI, which packs the tarball and checks every module/resolution combination, flagging exactly which is wrong. Add test -f dist/index.d.cts as a backstop. These resolve the types as a consumer will, so a mismatch fails your build.

How do I generate both .d.ts and .d.cts from one source?

Configure your bundler for dual format with declaration emit (tsup's dts: true), or run a tsc pass per format, so each declaration describes the module as that format presents it. Then wire each into the matching exports condition's types — emitting and wiring are separate steps that both must be correct.

Can I skip .d.cts if my package is mostly used with import?

Only if you never support require — but if the exports map has a require branch, a consumer using it under node16 resolution needs the .d.cts, or they get wrong types. If you truly don't support CommonJS consumers, ship ESM-only and drop the require branch entirely.

What does attw report when my .d.cts is missing?

It flags the require resolution cell as a types failure — typically 'no types' or a 'false CJS' result — showing that a require consumer under node16 finds no correct declaration. That specific cell tells you exactly which condition and format to fix.

Is a single .d.ts ever enough for a dual-format package?

Only under bundler or legacy node10 resolution, where the checker does not follow conditional exports. Under node16/nodenext — increasingly the default — a require consumer needs its own .d.cts, so emitting both is the safe choice for any package with a require branch.

Related

TypeScript Declaration Publishing