Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

ESM and CJS Interoperability

Implement deterministic, secure dual-module configurations for modern Node.js and frontend toolchains. This guide enforces strict resolution boundaries, eliminates dependency drift, and guarantees reproducible CI/CD execution.

Node.js Module Resolution & Package Configuration

Define explicit module boundaries using the exports field. Map conditional exports for import, require, and default to prevent ambiguous resolution. Configure type and conditional routing as detailed in Understanding package.json Fields to enforce strict parsing rules. Disable implicit fallbacks to mitigate prototype pollution and path traversal risks.

Conditional Exports Syntax

Use explicit conditional routing to direct bundlers and Node.js to the correct artifact. Never rely on implicit file resolution.

{
 "name": "@scope/dual-pkg",
 "version": "1.0.0",
 "type": "module",
 "exports": {
 ".": {
 "types": "./dist/index.d.ts",
 "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
 "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
 },
 "./internal/*": null
 }
}

Security Note: Setting "./internal/*": null explicitly blocks unauthorized path traversal into private directories.

Strict Type Enforcement

Node.js defaults to CommonJS when type is omitted. Enforce ESM at the package root or use explicit extensions:

# Verify package type resolution
node -e "console.log(require('./package.json').type)"

# If "type": "module" is set, all .js files parse as ESM.
# Use .cjs for legacy CommonJS entry points.

Security Implications of Fallback Resolution

Legacy main/module fields trigger unpredictable resolution chains. Attackers can exploit unguarded fallback paths to load arbitrary files or trigger prototype pollution via malformed import specifiers. Always terminate conditional chains with default and nullify internal paths.

Dual-Output Build Pipeline Architecture

Configure bundlers to emit parallel ESM and CJS artifacts. Enforce deterministic builds by pinning toolchain versions and validating outputs against a strict schema. Integrate build steps into Core JavaScript Package Workflows to guarantee reproducible CI/CD execution. Apply tree-shaking exclusively to ESM outputs to preserve CJS compatibility.

Rollup/esbuild/tsup Configuration

tsup provides zero-config dual-output generation with deterministic hashing.

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

export default defineConfig({
 entry: ['src/index.ts'],
 format: ['esm', 'cjs'],
 dts: true,
 splitting: false,
 sourcemap: true,
 clean: true,
 outDir: 'dist',
 // ESM-specific optimizations
 esbuildOptions(options) {
 options.target = 'node18';
 }
});

TypeScript Compiler Flags

Align the TypeScript compiler with Node.js native resolution. Prevent synthetic default imports that break strict ESM consumers.

{
 "compilerOptions": {
 "target": "ES2022",
 "module": "Node16",
 "moduleResolution": "Node16",
 "declaration": true,
 "declarationMap": true,
 "esModuleInterop": true,
 "isolatedModules": true,
 "strict": true,
 "skipLibCheck": true
 },
 "include": ["src/**/*"],
 "exclude": ["node_modules", "dist"]
}

Artifact Validation & Hash Verification

Validate build outputs in CI to prevent silent corruption or drift.

# .github/workflows/build-validate.yml
name: Validate Dual Outputs
on:
 push:
 branches: [main]
jobs:
 build:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4
 - uses: actions/setup-node@v4
 with:
 node-version: '20'
 cache: 'npm'
 - run: npm ci
 - run: npx tsup
 - name: Verify ESM/CJS Artifacts
 run: |
 test -f dist/index.mjs || { echo "ESM missing"; exit 1; }
 test -f dist/index.cjs || { echo "CJS missing"; exit 1; }
 # Generate deterministic SHA256 for release signing
 sha256sum dist/index.mjs dist/index.cjs > dist/SHASUMS.txt

Runtime Interoperability & Dynamic Loading

Implement safe interop bridges using createRequire for ESM-to-CJS and dynamic import() for CJS-to-ESM. Validate dynamic import paths against allowlists to prevent arbitrary code execution. Synchronize dependency resolution across environments using Lockfile Management Strategies to eliminate version drift in production.

createRequire Implementation

Bridge legacy CJS modules safely within ESM contexts.

import { createRequire } from 'module';
import path from 'path';

// ESM -> CJS Bridge
const require = createRequire(import.meta.url);
const legacyPkg = require('legacy-cjs-pkg');

// CJS -> ESM (Dynamic)
async function loadEsmModule(pkgName) {
 const resolved = path.resolve(process.cwd(), 'node_modules', pkgName);
 // Strict path validation prevents directory traversal
 if (!resolved.startsWith(process.cwd())) throw new Error('Path traversal blocked');
 return import(resolved);
}

Dynamic Import Security Controls

Never pass unsanitized user input to import(). Enforce strict allowlists and validate against node: built-ins.

const ALLOWED_MODULES = new Set(['lodash', 'chalk', 'uuid']);

export async function safeDynamicImport(moduleName) {
 if (!ALLOWED_MODULES.has(moduleName)) {
 throw new Error(`Module "${moduleName}" not in allowlist`);
 }
 return import(moduleName);
}

Cross-Environment State Management

ESM and CJS maintain separate module caches. Share state via explicit singletons or external stores (Redis, in-memory cache) rather than relying on require.cache or import.meta.url mutations.

Testing Matrix & Production Validation

Execute isolated test suites for ESM and CJS entry points. Configure Jest/Vitest with explicit transformIgnorePatterns and moduleFileExtensions. Validate Node.js version compatibility (18+ native ESM, 20+ stable). Enforce zero-dependency runtime checks where possible.

Environment-Specific Test Runners

Configure Vitest to run parallel ESM/CJS validation.

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

export default defineConfig({
 test: {
 globals: true,
 environment: 'node',
 // Force ESM resolution
 alias: {
 '@/': './src/'
 }
 }
});

Run isolated matrix tests:

# ESM Suite
node --experimental-vm-modules node_modules/.bin/vitest run --config vitest.config.ts

# CJS Suite (override type)
NODE_OPTIONS="--require tsx/cjs" npx vitest run --config vitest.cjs.config.ts

Node.js Version Compatibility Matrix

Node Version ESM Support CJS Interop Recommended Action
18.x Native Stable Baseline target. Enable --experimental-vm-modules for Jest if needed.
20.x Native Stable Default CI target. Full import() and createRequire support.
22.x Native Stable Enable --experimental-strip-types for TS execution.

Bundle Size & Tree-Shaking Audits

Verify that ESM artifacts are tree-shakeable and CJS artifacts contain no dead code.

# Analyze ESM bundle size
npx esbuild dist/index.mjs --bundle --minify --outfile=/dev/null --metafile=meta.json
npx esbuild-visualizer meta.json

# Verify CJS side-effects
grep -r "sideEffects" package.json || echo "Add \"sideEffects\": false to enable tree-shaking"

Common Pitfalls & Remediation

Mistake Consequence Fix
Relying on legacy main/module fields Ambiguous resolution order, bundler conflicts, security bypasses via unguarded fallback paths. Deprecate main/module in favor of strict exports maps with explicit conditional keys.
Omitting the default condition Node.js and bundlers fail to resolve when no specific condition matches, causing runtime crashes. Always include default as the final fallback in conditional export chains.
Mixing .js extensions without type Node.js defaults to CJS, breaking native ESM syntax and causing SyntaxError: Cannot use import statement outside a module. Use .mjs/.cjs extensions or explicitly set "type": "module" in package.json.
Using eval() or new Function() for interop Critical security vulnerability enabling arbitrary code execution and CSP violations. Use native import() with strict path validation and createRequire for synchronous CJS loading.

Frequently Asked Questions

Is it safe to use wildcard exports ("./utils/*": "./dist/utils/*") in production packages? No. Wildcards bypass strict path resolution, expose internal file structures, and complicate static analysis. Explicitly enumerate public entry points in the exports map.

How do I handle TypeScript esModuleInterop when publishing dual packages? Enable esModuleInterop in tsconfig.json to generate synthetic default imports for CJS consumers, but ensure your build output strips these shims for native ESM consumers to avoid duplicate exports.

Why does Node.js throw ERR_REQUIRE_ESM when importing my package? The package lacks a valid require condition in its exports map, or the target file is explicitly marked as ESM via .mjs or "type": "module" without a CJS fallback. Add a require conditional export pointing to a compiled .cjs artifact.

Should I ship source maps for both ESM and CJS outputs? Yes, but only in development or via a separate @types/-debug package. In production, strip source maps to reduce bundle size and prevent reverse engineering of proprietary logic.