DEV Community

Cover image for Dual ESM/CJS Publish in 2026: A Modern tsconfig + package.json Setup That Just Works
Gabriel Anhaia
Gabriel Anhaia

Posted on

Dual ESM/CJS Publish in 2026: A Modern tsconfig + package.json Setup That Just Works


A team I talked to shipped a TypeScript utility library to
npm last quarter. It built. It published. The tests passed in
their monorepo. Then external users started filing issues.
One user reported that require('@team/core') returned an
empty object in Node 20. Another reported that Vitest could
not resolve the types and threw Cannot find module
'@team/core'
. A third said Bun loaded the package fine but
imported the wrong file. Same package, four runtimes, four
behaviors.

That library had a main field, a module field, a types
field, and one dist directory. In 2018 that was enough. In
2026 it is the shape that produces the four-issue welcome
above. What ships clean today is a package with an exports
map and two output trees, one ESM and one CJS. The conditions
in that map are ordered so Node, Bun, Deno, and bundlers all
resolve correctly on the first try.

Two tsconfigs, one package.json, the ordering rules nobody
writes down, and the foot-guns every library author hits
eventually.

Why dual publishing is still a thing in 2026

ESM-only would be cleaner. Some libraries ship that way and
recommend you upgrade. The reality on the ground is that a
substantial chunk of the npm dependency graph still uses CJS
under the hood, and a CJS consumer cannot require() a
package that ships only .mjs. Top-level await, import
syntax in a CJS file, and the directory layout you'd want to
use are all blocked.

Until your audience is fully ESM, dual publishing is the
honest answer. Things stabilized in 2024. The
Node.js exports field documentation
is stable, TS 5+ resolves it, Bun and Deno honor it, and
modern bundlers (esbuild, Rollup, Rolldown, Vite) treat it as
the single source of truth.

What you need to produce:

  • dist/esm/index.js — a real ES module
  • dist/cjs/index.cjs — a real CommonJS module
  • dist/esm/index.d.ts — types matching the ESM build
  • dist/cjs/index.d.cts — types matching the CJS build
  • A package.json that maps every consumer to the right file

The two declaration files are the part most posts skip.

The package.json that just works

Start with the package.json. The exports field is the only
entry-point declaration that modern resolvers respect. main
and types are kept as fallbacks for tooling that hasn't
caught up.

{
  "name": "@team/core",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js",
  "types": "./dist/esm/index.d.ts",
  "exports": {
    ".": {
      "types": {
        "import": "./dist/esm/index.d.ts",
        "require": "./dist/cjs/index.d.cts"
      },
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist"],
  "sideEffects": false
}
Enter fullscreen mode Exit fullscreen mode

"type": "module" makes every .js file in the package an ES
module by default. The CJS build emits .cjs files because
they need a different extension to opt out of that default.
The alternative is "type": "commonjs" with the ESM build
using .mjs. Either works; the form above is the one most
modern libraries pick because the ESM tree is the one you
treat as primary.

The exports map is read top-down by Node. The first matching
condition wins. That ordering is why types comes first: the
TypeScript compiler reads the same map, and if import or
require appears before types, TypeScript will pick the JS
file path and try to find .d.ts next to it. That works for
some users and silently fails for others. Putting the types
condition first forces every TS resolver to find your typings
before it ever resolves the JS.

Inside the types block, you split again by import vs
require. ESM consumers get index.d.ts; CJS consumers get
index.d.cts. They must be separate files because the
TypeScript declaration resolver tracks whether the caller is
in an ESM or CJS context, and a .d.ts file declared with
ESM-style export syntax won't match a require() call
cleanly. Callers using moduleResolution: bundler will see a
This expression is not callable error on functions you
clearly export, with no usable hint at the cause.

Exporting ./package.json is the rule nobody remembers until
a tool blows up. Bundlers, framework loaders, and some build
plugins read your package.json directly to figure out the
package's type, version, or peer-deps shape. With strict
exports enforcement (the default in modern Node), they get
denied unless you explicitly export the file.

The two tsconfigs

You need two compiler invocations because TypeScript can't
emit ESM and CJS in the same pass. The clean shape is a base
tsconfig with the type-checking settings, then two thin
tsconfigs that override the module/output settings.

tsconfig.base.json:

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"],
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.esm.json:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "es2022",
    "moduleResolution": "bundler",
    "outDir": "./dist/esm"
  }
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.cjs.json:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node10",
    "outDir": "./dist/cjs",
    "verbatimModuleSyntax": false
  }
}
Enter fullscreen mode Exit fullscreen mode

The CJS config drops verbatimModuleSyntax because that flag
forbids the import x = require(...) form CJS sometimes
needs. It also downgrades moduleResolution to node10 so it
matches the CJS consumer's runtime behaviour. (node10 is
what older docs call node; same algorithm, renamed in TS 5.)

Two extra steps make the output match the package.json claims.

After tsc -p tsconfig.cjs.json finishes, the .js files in
dist/cjs/ need to be renamed to .cjs, and their internal
declaration files need the .d.cts extension too. TypeScript
won't do this for you. It emits .js regardless of the
module setting. On top of that, the CJS tree needs a
package.json of its own that flips type back to
commonjs. Without it, Node still treats the directory as
ESM (because the parent package says so) and loading a .cjs
file inside an ESM context can produce interop errors with
downstream tooling.

The cleanest place to do both jobs is one post-build script,
which keeps the build cross-platform (no shell echo
redirects, no Windows quote-handling traps):

// scripts/rename-cjs.mjs
import { readdir, rename, writeFile }
  from "node:fs/promises";
import { join, extname } from "node:path";

async function walk(dir) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const e of entries) {
    const p = join(dir, e.name);
    if (e.isDirectory()) {
      await walk(p);
    } else if (extname(e.name) === ".js") {
      await rename(p, p.replace(/\.js$/, ".cjs"));
    } else if (extname(e.name) === ".d.ts") {
      await rename(p, p.replace(/\.d\.ts$/, ".d.cts"));
    }
  }
}

await walk("dist/cjs");
await writeFile(
  "dist/cjs/package.json",
  JSON.stringify({ type: "commonjs" }) + "\n"
);
Enter fullscreen mode Exit fullscreen mode

Wire the whole thing in package.json scripts:

{
  "scripts": {
    "build:esm": "tsc -p tsconfig.esm.json",
    "build:cjs": "tsc -p tsconfig.cjs.json && node scripts/rename-cjs.mjs",
    "build": "npm run build:esm && npm run build:cjs",
    "prepublishOnly": "npm run build"
  }
}
Enter fullscreen mode Exit fullscreen mode

How conditional ordering actually resolves

The exports field looks like a JSON object. It behaves like
a top-down decision tree. The Node resolution algorithm walks
the conditions in the order you wrote them and picks the
first one that matches.

types always goes first inside any conditional block. If
you put it anywhere else, TypeScript compilers using
moduleResolution: bundler or node16/nodenext will
sometimes resolve the JS path and try to derive types from
adjacent files. That produces "Could not find a declaration
file for module" errors for a percentage of users you can't
reproduce locally. Always types first.

default is a sink, not a fallback for "anything I forgot."
When a resolver hits default, it means none of the named
conditions matched. Use it to handle environments you don't
recognize (old bundlers, edge runtimes that don't declare
themselves), not as a shortcut for "ESM consumers." The
specific conditions, like import, require, node,
browser, deno, and bun, should always be more specific
than default.

Order matters between siblings but not between branches.
Inside the types block, import and require can appear in
either order; they're mutually exclusive (a given import is
either ESM or CJS). But at the top level of an entry, where
you have types, import, require, and maybe default as
siblings, the order is read top-down and the first match
wins. Standard order for a dual library:

{
  ".": {
    "types": { "import": "...", "require": "..." },
    "node": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    },
    "browser": "./dist/esm/index.js",
    "import": "./dist/esm/index.js",
    "require": "./dist/cjs/index.cjs",
    "default": "./dist/esm/index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

If you ship a browser-specific bundle, the browser key is
where it goes. Bun and Deno honor node first when present,
which is usually what you want. They implement the Node
algorithm closely enough that pointing them at the Node build
is correct.

If you only need an ESM/CJS split, the simpler exports form
shown earlier is enough. The expanded shape above adds
Bun/Deno/browser routing when you need it.

Foot-guns that bite real libraries

The "default condition trap." Putting default at the top of
the block means every consumer hits it before any specific
condition is checked. The library still loads, but you've
silently pinned everyone to one build. Always keep default
last.

Missing .cts declarations are the second classic. A user on
tsc with moduleResolution: node16 and CJS compilation
imports your package and gets "This expression is not
callable"
on a function you definitely export. The cause:
TypeScript matched the require condition for the JS, walked
up to find typings, and used the .d.ts file (which is in ESM
syntax). The fix is the dual .d.ts/.d.cts pair.

Another bite: subpath exports. If your package has
sub-entries like @team/core/utils or @team/core/errors,
every sub-entry needs its own exports block with the same
condition shape. Forgetting one makes that import path
unreachable for some users even though it works locally.
Easiest fix: a small generator that walks src/ and emits the
sub-entries automatically as part of the build.

Publishing the wrong files is the loudest one when it bites.
Without a files array in package.json, npm publish ships
your repo's tsconfig, scripts, tests, and probably a
.env.example. The files array (or a .npmignore) keeps
publishes lean. With the setup above, "files": ["dist"] is
enough.

Drift between builds is the subtler one. Both compilers emit
declarations from the same source, so they should produce
identical types, unless you reach for import.meta.url
(ESM-only) or __dirname (CJS-only) in typed code. Isolate
runtime-specific code in two files and let conditional exports
route each consumer to the right one.

Verifying the publish before you publish

The thing that catches everything above is a publish-rehearsal
step. Two free tools:

@arethetypeswrong/cli
runs every condition in your exports map and reports which
combinations resolve to wrong types or missing files. Run it
in CI:

npx @arethetypeswrong/cli --pack .
Enter fullscreen mode Exit fullscreen mode

publint checks the package.json
itself for shape errors that would silently misroute users.
Run that too:

npx publint
Enter fullscreen mode Exit fullscreen mode

Between the two, most dual-publish bugs reported in the wild
surface as a red line. Wire both into CI and reject PRs that
break the publish surface. It pays off the first time it
stops a regression.

What to do with this on Monday

If you maintain a TypeScript library that ships to npm:

Open package.json. If you have main, module, and types
but no exports map, you have the 2018 shape. The next
release should add an exports block in the order shown
above, with types first, then import/require, and
default last.

Split your tsconfig into a base + ESM + CJS triplet. The
build pipeline is twenty lines once the rename script and
inner package.json write are in place. Wire them into
prepublishOnly so nobody can ship a half-baked build.

Add arethetypeswrong and publint to CI on the same job
that runs your tests. Both run in under a second on most
projects.

Do this once and the four-issue welcome never reaches your
inbox. The issues that do come in will be about the library
itself, which is the work.


If this was useful

TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes in The TypeScript Library picks up where this post leaves off — the chapter on dual publishing walks through monorepo variants, JSR publishing alongside npm, and the CI matrix you want when supporting Node, Bun, Deno, and the browser as first-class targets. It is one of five books in the collection:

  1. TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
  2. The TypeScript Type System — the deep dive. Generics, mapped and conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
  4. PHP to TypeScript — bridge for PHP 8+ developers. Sync to async, generics, discriminated unions.
  5. TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books 1 and 2 are the core path. If you maintain a library that ships to npm, Book 5 is the one that matches this post's audience directly. Book 5 is for anyone shipping TypeScript at work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)