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

ESM vs CJS Evaluation Flow Left panel shows ESM: parse all imports statically, build dependency graph, evaluate leaves first, expose live bindings. Right panel shows CJS: execute require() at runtime, cache result, return value copy of module.exports. ESM — Static Graph CJS — Runtime require() 1. Parse all import declarations 2. Build full dependency graph (before any code runs) 3. Evaluate leaves → root 4. Expose live bindings (mutations visible to all importers) top-level await suspends evaluation 1. Execute require() call 2. Run module file synchronously 3. Cache in require.cache 4. Return value copy of exports (mutations NOT visible after copy) circular require() may return partial exports

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".js files are parsed as ESM
  • "type": "commonjs" (or field omitted) → .js files 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 PREVENTIONtsup default output names conflict when both formats are requested without splitting disabled. If you see index.js overwriting itself, add --no-splitting or set splitting: false in tsup.config.ts. Each format must land in a distinct filename so the exports map 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 the exports map blocks tools that attempt require('your-package/package.json') to read metadata. This includes some TypeScript resolution strategies and bundler plugins. Always expose package.json explicitly.


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" in tsconfig.json while emitting .mjs artifacts causes a mismatch: tsc will accept bare specifier imports without extensions, which will then throw ERR_MODULE_NOT_FOUND at Node.js runtime. Always pair "module": "Node16" with .mjs output.


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 PREVENTIONMixing require() and import() in the same module scope causes ERR_REQUIRE_ESM in Node.js when require() attempts to load an ESM-only file. Isolate entry points by format: keep CJS consumers on .cjs paths and ESM consumers on .mjs paths. Use dynamic import() within CJS files only when you intentionally need to load ESM asynchronously. For a worked example of this failure, see Fixing require() Errors in Pure ESM Packages.

HAZARD PREVENTIONinstanceof failures 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 PREVENTIONLegacy node or node10 moduleResolution in tsconfig ignores the exports field entirely. TypeScript will resolve imports against main and module, 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-type options, and NODE_OPTIONS values that activate native ESM in Node.js 14 through 22, including test runner configuration for Jest and the built-in node:test module.
  • Converting a CommonJS Library to ESM — a step-by-step migration from require/module.exports to import/export, replacing __dirname, and keeping a CJS build for existing consumers.


← Module System Fundamentals & Dual-Package Resolution