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.
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.
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.
- 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.
{
"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 — 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.