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.
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+declarationMapbelong in your shared compiler config; the per-pass--moduleoverride is all that changes between ESM and CJS declaration emits.- Set
verbatimModuleSyntax: truesotscdoes not rewriteimport/exportsyntax in ways that desync the declaration from the emitted JS. - A
.d.ctsdescribingmodule.exports = ...should useexport = Foo;sorequire()consumers see the value directly; a.d.mtsusesexport 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 --packcheck 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.mtsanddist/index.d.ctsexist as a post-build step so a dropped declaration fails fast. - Verify
typesprecedesdefaultin everyexportscondition during code review. - Pin
typescriptandtsupversions 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 — the full picture of
typesfields,exportsordering, and declaration emit. - Fixing 'Cannot Find Module' Type Declaration Errors — diagnosing the
ts(2307)/ts(7016)errors that a wrong dual setup triggers. - ESM and CJS Interoperability — the runtime dual-format mechanics your declarations must mirror.
- Understanding package.json Fields — the
exports,main, andtypesfields that route consumers.