Understanding ESM vs CJS Module Formats
ESM live bindings vs CJS value copies, top-level await, runtime resolution mechanics, and TypeScript interoperability for dual-format npm package distribution.
Without a precise understanding of how ECMAScript Modules and CommonJS differ at the evaluation and binding level, dual-format packages fail in subtle, hard-to-reproduce ways: consumers get stale values from live exports, instanceof checks return false across module boundaries, and tsc emits errors that only appear under moduleResolution: "node16" or later. These issues surface reliably from Node.js 12 onward as native ESM support matured, and consistently catch library authors who treat the two formats as interchangeable.
For the architectural decisions that sit above individual format choices — when to ship dual-format versus ESM-only, and how the Node.js module cache separates the two systems — see Module System Fundamentals & Dual-Package Resolution.
Prerequisites
Before implementing dual-format distribution, confirm the following:
The Core Difference: Evaluation and Binding Semantics
CJS evaluates modules synchronously at runtime. A call to require() executes the module file immediately, stores the result in require.cache, and returns a shallow copy of module.exports at the moment of execution. Later mutations to internal variables inside the exporter are invisible to any consumer that already holds the copy.
ESM parses the entire import graph statically before executing anything. The runtime builds a dependency graph from import declarations, evaluates each module in dependency order, and exposes live bindings — named references that always reflect the current value of the exporting module’s variable, not a snapshot.
The difference is not academic. It determines whether consumers see mutations:
// lib.ts (compiled to lib.mjs for ESM consumers)
export let requestCount = 0;
export function trackRequest(): void { requestCount++; }
// consumer.mts — ESM consumer sees live binding
import { requestCount, trackRequest } from './lib.js';
console.log(requestCount); // 0
trackRequest();
console.log(requestCount); // 1 — live binding reflects the mutation
// consumer.cjs — CJS consumer receives a value copy
const { requestCount, trackRequest } = require('./lib.cjs');
console.log(requestCount); // 0
trackRequest();
console.log(requestCount); // still 0 — copy was taken at require() time
This asymmetry matters when your package exports counters, registries, or any mutable state. CJS consumers of an ESM-compiled artifact accessed via dynamic import() do receive live bindings through the Module Namespace Object — but only when the CJS file calls import() rather than require().
ESM also enables top-level await, allowing a module to perform async initialization — fetching remote config, opening a database connection, reading a key file — before it is considered evaluated. Any importer waits for that promise to resolve before its own body runs. CJS has no equivalent; everything must be synchronous or wrapped inside an async function that the consumer calls explicitly.
Execution context differs too: ESM modules run in strict mode by default and have no implicit module, exports, __dirname, or __filename globals. CJS wraps every file in a function closure that provides all four. When porting code, replace __dirname with new URL('.', import.meta.url).pathname and __filename with new URL(import.meta.url).pathname.
ESM vs CJS: Evaluation Flow
Step 1 — Configure File Extensions and the type Field
The type field in package.json sets the default interpretation of .js files in that package tree:
"type": "module"→.jsfiles are parsed as ESM"type": "commonjs"(or field omitted) →.jsfiles are parsed as CJS
Explicit extensions always override type, regardless of what package.json declares:
.mjs→ always ESM.cjs→ always CJS
For dual-format packages, set "type": "module" at the package root and emit CJS artifacts with the .cjs extension. This keeps your source and ESM output in .ts/.js/.mjs, and makes the CJS artifact unambiguous to Node.js:
{
"name": "my-library",
"version": "1.0.0",
"type": "module"
}
For native ESM, relative imports must include the full file extension. The Node.js ESM resolver does not perform extension guessing:
// Correct in ESM — extension is mandatory
import { parse } from './parser.js';
// ERR_MODULE_NOT_FOUND in Node.js ESM
import { parse } from './parser';
For runtime flags that activate native ESM in Node.js and configure test runners, see How to Configure Node.js for Native ESM Support.
Step 2 — Set Up a Parallel Build Pipeline
The canonical way to emit both formats from a single TypeScript source is tsup. One config entry produces .mjs and .cjs artifacts plus their declaration files:
{
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"postbuild": "node scripts/validate-outputs.mjs"
}
}
tsup renames ESM output to .mjs and CJS output to .cjs automatically when --format esm,cjs is specified together. The --dts flag invokes tsc under the hood and emits .d.ts files alongside each artifact; with moduleResolution: "Node16", tsc will emit .d.mts for the ESM artifact as well.
HAZARD PREVENTION —
tsupdefault output names conflict when both formats are requested withoutsplittingdisabled. If you seeindex.jsoverwriting itself, add--no-splittingor setsplitting: falseintsup.config.ts. Each format must land in a distinct filename so theexportsmap can address them separately.
Step 3 — Wire the Canonical exports Map
The exports field is the authoritative contract that routes ESM and CJS consumers to the correct artifact. Condition keys must appear in priority order — types first, default last:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
The "main" and "module" fields are legacy fallbacks for bundlers (Webpack 4, older Rollup) that do not read exports. Keep them as fallbacks but never rely on them for Node.js 12+ resolution — exports always wins when both are present.
HAZARD PREVENTION — Omitting
"./package.json": "./package.json"from theexportsmap blocks tools that attemptrequire('your-package/package.json')to read metadata. This includes some TypeScript resolution strategies and bundler plugins. Always exposepackage.jsonexplicitly.
Step 4 — Align TypeScript Compiler Settings
Set module and moduleResolution to match your target runtime. Node16 and NodeNext make TypeScript enforce the same resolution rules as modern Node.js — explicit .js extensions on relative imports, exports field awareness, and .d.mts/.d.cts declaration emission when the source file extension warrants it:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"strict": true,
"outDir": "./dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
The import = require() syntax is CJS-specific and breaks ESM compatibility under strict moduleResolution:
// Avoid in ESM-first codebases — CJS-only syntax
import fs = require('fs');
// Correct alternatives
import fs from 'fs';
// Or for lazy, format-agnostic loading:
const fs = await import('fs');
HAZARD PREVENTION — Setting
"module": "CommonJS"intsconfig.jsonwhile emitting.mjsartifacts causes a mismatch: tsc will accept bare specifier imports without extensions, which will then throwERR_MODULE_NOT_FOUNDat Node.js runtime. Always pair"module": "Node16"with.mjsoutput.
Step 5 — Run a CI Matrix Across Both Formats
A CI matrix that validates both formats across Node.js LTS versions catches format-specific bugs before they reach consumers. Especially important: run your test suite against the built artifacts, not the TypeScript source, to catch extension and resolution errors that only appear after compilation:
name: Dual-Format Validation
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
format: [esm, cjs]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run build
- name: Run Format-Specific Tests
run: |
if [ "${{ matrix.format }}" == "esm" ]; then
node --experimental-vm-modules --test test/esm/*.test.mjs
else
node --test test/cjs/*.test.cjs
fi
- name: Bundle Size Audit
run: npx size-limit
Step 6 — Validate with Tooling Before Publishing
These commands confirm that the exports map, declaration files, and format artifacts are correctly wired before npm publish reaches consumers:
# Check exports map, main/module fields, and entry point reachability
npx publint
# Verify TypeScript declaration files match the format of each export condition
npx @arethetypeswrong/cli .
# Type-check source without emitting — catches tsconfig mismatches
npx tsc --noEmit
# Inspect what Node.js would resolve for each condition
node --input-type=module -e "
import { createRequire } from 'module';
const req = createRequire(import.meta.url);
console.log(req.resolve('my-library')); // CJS path
import('my-library').then(m => console.log(m)); // ESM path
"
Sample publint output on a correctly configured package:
✔ exports["."]["types"] resolves to ./dist/index.d.ts
✔ exports["."]["import"] resolves to ./dist/index.mjs (ESM)
✔ exports["."]["require"] resolves to ./dist/index.cjs (CJS)
✔ No issues found
Sample attw output:
my-library
"." exports
ESM → OK (dist/index.mjs)
CJS → OK (dist/index.cjs)
Types → OK (dist/index.d.ts)
Hazard Call-Outs
HAZARD PREVENTION — Mixing
require()andimport()in the same module scope causes ERR_REQUIRE_ESM in Node.js whenrequire()attempts to load an ESM-only file. Isolate entry points by format: keep CJS consumers on.cjspaths and ESM consumers on.mjspaths. Use dynamicimport()within CJS files only when you intentionally need to load ESM asynchronously. For a worked example of this failure, see Fixingrequire()Errors in Pure ESM Packages.
HAZARD PREVENTION —
instanceoffailures across module format boundaries occur because ESM and CJS each get their own module cache. If your package exports a class and consumers on different formats load it, objects constructed in one format are not instances of the class from the other format. This is a symptom of the dual-package hazard and requires centralizing the class in a single shared artifact (typically CJS) while re-exporting it from the ESM wrapper.
HAZARD PREVENTION — Legacy
nodeornode10moduleResolution in tsconfig ignores theexportsfield entirely. TypeScript will resolve imports againstmainandmodule, which may point to different files than what Node.js actually loads at runtime. This causes “works in tests, breaks in production” bugs. Always use"moduleResolution": "Node16"or"Bundler"when your package uses conditional exports.
Compatibility Matrix
| Environment | "type":"module" in pkg |
exports field |
Top-level await |
Live bindings |
|---|---|---|---|---|
| Node.js 12 | Partial (flag required) | No | No | No |
| Node.js 14 | Yes (unflagged) | Yes | No | Yes |
| Node.js 16 | Yes | Yes | Yes | Yes |
| Node.js 18 LTS | Yes | Yes | Yes | Yes |
| Node.js 20 LTS | Yes | Yes | Yes | Yes |
| Node.js 22 LTS | Yes | Yes | Yes | Yes |
| Webpack 4 | No (reads module field) |
No | No | Simulated |
| Webpack 5 | Yes | Yes | Yes | Yes |
| Vite 4+ | Yes | Yes | Yes | Yes |
| Rollup 4+ | Yes | Yes | Yes | Yes |
TypeScript 4.7+ (Node16) |
Yes | Yes | Yes | Yes |
TypeScript 5.0+ (NodeNext) |
Yes | Yes | Yes | Yes |
Pages in This Section
- How to Configure Node.js for Native ESM Support — The exact flags,
--input-typeoptions, andNODE_OPTIONSvalues that activate native ESM in Node.js 14 through 22, including test runner configuration for Jest and the built-innode:testmodule. - Converting a CommonJS Library to ESM — a step-by-step migration from
require/module.exportstoimport/export, replacing__dirname, and keeping a CJS build for existing consumers.
Related
- Mastering the
package.jsonExports Field — Deep dive into conditional export syntax, priority ordering, and how to route TypeScript, ESM, and CJS consumers from a single entry map. - Navigating the Dual-Package Hazard — What breaks when the same package loads twice in one process and how to prevent singleton state corruption.
- Browser vs Node.js Module Resolution — How resolution differs between Node.js native ESM, bundlers, and browser-native
<script type="module">, and whichexportsconditions to use for each. - Optimizing tsconfig.json for Library Distribution —
module,moduleResolution,declaration, anddeclarationMapsettings that match dual-format output. - Modern Build Tools: tsup, Rollup, and esbuild — Choosing and configuring a build tool to emit correctly named
.mjsand.cjsartifacts.