Modern JavaScript ecosystems operate across divergent runtime environments, making the choice between module formats a foundational architectural decision. As library maintainers, platform engineers, and DevOps teams scale cross-environment distributions, Module System Fundamentals & Dual-Package Resolution provides the necessary context for navigating this landscape. This guide dissects the execution models, resolution mechanics, and build configurations required to ship robust, dual-format packages without introducing runtime fragmentation or type resolution failures.

Core Architectural Differences & Execution Models

The divergence between CommonJS (CJS) and ECMAScript Modules (ESM) is rooted in their evaluation timing, binding semantics, and execution context isolation.

CJS relies on synchronous require() calls, executing modules at runtime with dynamic evaluation. Each require() returns a shallow copy of the module.exports object. ESM, conversely, uses declarative import statements evaluated asynchronously during the parse phase. This enables static analysis, allowing bundlers and runtimes to perform aggressive tree-shaking and eliminate dead code paths before execution.

A critical architectural distinction is live bindings in ESM versus value copies in CJS. ESM exports maintain a live reference to the original variable. Mutating an exported value in one module immediately reflects in all consumers. CJS exports a snapshot at evaluation time, requiring explicit re-assignment or function wrappers to propagate state changes.

// ESM: Live Binding Behavior
// lib.mjs
export let counter = 0;
export function increment() { counter++; }

// consumer.mjs
import { counter, increment } from './lib.mjs';
console.log(counter); // 0
increment();
console.log(counter); // 1 (Live binding updates automatically)

ESM also introduces top-level await, enabling asynchronous initialization patterns (e.g., fetching configuration, establishing DB connections) without wrapping modules in IIFEs. However, this shifts module evaluation to an asynchronous phase, requiring careful orchestration in synchronous CJS consumers.

Execution context isolation differs significantly. ESM modules run in strict mode by default and maintain isolated lexical scopes. CJS modules wrap code in a function closure, implicitly exposing module, exports, __dirname, and __filename. When mixing formats, state sharing conflicts frequently arise. For comprehensive mitigation strategies regarding runtime isolation failures, consult Navigating the Dual-Package Hazard.

Runtime Configuration & Legacy Compatibility Node.js requires explicit flags to enable experimental ESM features in older versions or to bridge VM contexts.

# Enable experimental VM modules for isolated test runners
NODE_OPTIONS="--experimental-vm-modules" node --test

# Force ESM evaluation for legacy scripts without .mjs extension
node --input-type=module -e "import { readFileSync } from 'fs'; console.log(readFileSync('/etc/hostname', 'utf-8'));"

Hazard Prevention: Never mix synchronous require() and asynchronous import() in the same module scope. Strictly isolate CJS and ESM entry points. Use dynamic import() for cross-format lazy loading to prevent synchronous blocking of the event loop during ESM graph resolution.

Resolution Mechanics & File Extension Conventions

Module resolution dictates how runtimes and bundlers locate dependencies. Node.js employs a deterministic algorithm prioritizing file extensions, package.json hints, and directory indexing.

The type field in package.json establishes the default module format for .js files within that package:

  • "type": "module".js files are parsed as ESM.
  • "type": "commonjs" (or omitted) → .js files are parsed as CJS.

Explicit extensions override the type field:

  • .mjs → Always ESM
  • .cjs → Always CJS

Bare specifier resolution traverses node_modules upward from the importing file’s directory until a matching package.json or file is found. Directory indexing falls back to index.js, index.mjs, or index.cjs depending on the nearest type declaration.

// package.json (Root configuration)
{
  "name": "@scope/library",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs"
}

For detailed runtime environment setup and native ESM execution flags, refer to How to Configure Node.js for Native ESM Support.

Bundler Resolver Overrides Modern bundlers (Vite, Webpack, esbuild) often implement custom resolution layers. Explicitly configure fallbacks to prevent ambiguous extension matching.

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    extensions: ['.mjs', '.js', '.ts', '.cjs'],
    conditions: ['import', 'require', 'node'],
  },
});

Hazard Prevention: Omitting explicit .mjs/.cjs extensions in cross-format codebases causes silent resolution failures in strict environments. Always use explicit extensions in internal imports, and configure bundler resolve.extensions to prioritize ESM artifacts first.

Dual-Package Distribution & CI/CD Pipeline Configuration

Shipping a single codebase as both ESM and CJS requires parallel build pipelines, explicit conditional exports, and rigorous CI validation.

Build Toolchain Selection Tools like tsup or Rollup efficiently generate dual outputs from a unified TypeScript source.

// package.json (Build scripts)
{
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts --clean",
    "postbuild": "node scripts/validate-outputs.mjs"
  }
}

Conditional Exports Mapping The exports field in package.json dictates entry point routing based on consumer environment. For advanced routing and fallback configurations, see Mastering the package.json Exports Field.

// package.json (Exports configuration)
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    },
    "./package.json": "./package.json"
  }
}

CI Matrix Testing Strategy Validate both formats simultaneously using a GitHub Actions matrix. This prevents format-specific runtime bugs from reaching production.

# .github/workflows/dual-format-validation.yml
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

Hazard Prevention: CI pipelines that only test a single module format miss dual-package runtime bugs. Implement a matrix workflow that executes identical test suites against both ESM and CJS build artifacts before publishing. Always include a root default fallback in exports pointing to a universal entry point to maintain backward compatibility with legacy bundlers.

TypeScript Interoperability & Type Resolution

TypeScript bridges ESM and CJS through compiler flags and resolution strategies. Misalignment between tsconfig.json and runtime behavior is a primary source of type resolution failures.

Compiler Configuration Align module and moduleResolution with your target runtime. node16 or nodenext enforces accurate ESM/CJS type mapping and respects package.json exports during type checking.

// tsconfig.json
{
  "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"]
}

Declaration File Generation When compiling dual formats, generate .d.ts files that match the module resolution strategy. moduleResolution: node16 ensures TypeScript emits .d.mts and .d.cts extensions when appropriate, preventing type mismatch warnings in strict consumers.

Avoiding Legacy Syntax The import = require() syntax is CJS-specific and breaks ESM compatibility. Replace it with standard ESM imports or dynamic import() calls.

// ❌ Avoid in ESM-first codebases
import fs = require('fs');

// ✅ ESM-compatible alternative
import fs from 'fs';
// or for dynamic loading
const fs = await import('fs');

Hazard Prevention: Incorrect moduleResolution in tsconfig.json causes type resolution failures in Node.js. Upgrade to moduleResolution: node16 or nodenext and align the module compiler option to match, ensuring accurate ESM/CJS type mapping across dual-package distributions.

Common Pitfalls & Resolution Matrix

Issue Root Cause Production Fix
Mixing require() and import() in the same scope Synchronous CJS evaluation clashes with asynchronous ESM graph resolution Isolate entry points by format. Use dynamic import() for cross-format lazy loading.
tsconfig.json type resolution failures Legacy node or node10 resolution ignores exports field Set moduleResolution: "node16" or "nodenext". Align module compiler option accordingly.
Legacy bundler failures on publish Missing fallback in exports mapping Add "default": "./dist/index.mjs" at the same level as import/require.
CI misses dual-package runtime bugs Single-format test execution Implement GitHub Actions matrix testing against both .mjs and .cjs artifacts.