Module System Fundamentals & Dual-Package Resolution
Master ESM vs CJS execution models, the dual-package hazard, and the package.json exports field for safe, tree-shakeable dual-format npm package distribution.
Core Module System Paradigms
JavaScript’s module ecosystem operates on two distinct execution models: CommonJS (CJS) and ECMAScript Modules (ESM). CJS relies on synchronous require() evaluation, executing code at runtime and caching the result. ESM uses static import declarations, enabling compile-time analysis and asynchronous loading. This divergence dictates how Node.js resolves dependencies and traverses node_modules.
The static nature of ESM allows bundlers to perform aggressive tree-shaking. Dead code is eliminated before deployment. CJS’s dynamic evaluation prevents reliable static analysis, forcing bundlers to include entire dependency graphs. For a deeper breakdown of these execution models, refer to Understanding ESM vs CJS Module Formats.
// CJS: Synchronous, runtime evaluation
const { parse } = require('./parser.js');
// ESM: Static, compile-time analysis
import { parse } from './parser.js';
Node.js defaults to CJS resolution unless "type": "module" is declared in package.json or the file uses .mjs. Understanding these defaults prevents resolution errors in hybrid projects and establishes a baseline for dual-format distribution.
The Dual-Package Hazard Explained
The dual-package hazard occurs when a single dependency is instantiated twice within the same process. Node.js maintains separate internal caches for CJS and ESM. When an application imports a package via require() and another dependency imports the same package via import, two distinct module instances are created.
This isolation breaks singleton patterns and causes state leakage. Global registries, event emitters, or shared caches will operate independently across the format boundary. For a comprehensive breakdown of this runtime divergence, see Navigating the Dual-Package Hazard.
// app.cjs
const stateA = require('shared-state');
stateA.set('key', 'value');
// app.mjs
import { state as stateB } from 'shared-state';
console.log(stateB.get('key')); // undefined (isolated cache)
Mitigation requires enforcing a single entry point that delegates to both formats. Unified exports and careful package structuring prevent cache bifurcation and ensure consistent runtime state.
Configuring the Exports Field
The exports field replaces legacy main and module declarations with a strict routing mechanism. It enforces explicit path resolution, blocking unauthorized access to internal directories. Subpath exports enable granular control over public APIs while keeping implementation details private.
Condition ordering is critical. The types condition must precede runtime conditions to ensure TypeScript resolves declarations first. A default fallback guarantees compatibility with legacy resolvers. Detailed configuration strategies are covered in Mastering the package.json Exports Field.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
},
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"import": "./dist/utils/*.mjs",
"require": "./dist/utils/*.cjs"
}
}
}
Strict resolution prevents path traversal attacks. Always validate your exports map against the actual build output to avoid 404 resolution errors in consuming projects.
Cross-Environment Resolution Strategies
Module resolution behavior varies significantly across Node.js, modern browsers, and bundler ecosystems. Node.js natively supports the node: protocol for built-in modules, bypassing node_modules lookups entirely. Browsers lack this protocol and require explicit polyfills or conditional routing.
Environment-specific conditional exports allow packages to serve optimized implementations per target. Path mapping and alias resolution in bundlers can override standard resolution. Explore environment-specific routing in Browser vs Node.js Module Resolution.
{
"exports": {
".": {
"browser": {
"import": "./dist/browser.mjs",
"require": "./dist/browser.cjs"
},
"node": {
"import": "./dist/node.mjs",
"require": "./dist/node.cjs"
}
}
}
}
When targeting browsers, avoid Node-specific globals in default exports. Use conditional routing to isolate platform-specific code and prevent runtime crashes in client-side environments.
Advanced Conditional Exports & TypeScript Integration
TypeScript 5+ introduces moduleResolution: bundler and node16/nodeNext, aligning compiler behavior with modern conditional exports. The import condition takes precedence over require in ESM contexts. TypeScript requires explicit types routing to avoid resolution mismatches. Dual-build toolchains must synchronize .d.ts outputs with their corresponding .mjs and .cjs artifacts.
Orchestration typically involves tsc for declarations and esbuild or rollup for runtime formats. Declaration files must mirror exact export signatures to prevent type drift. Advanced routing techniques are detailed in Advanced Conditional Exports Patterns.
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationDir": "./dist/types"
}
}
Ensure your build pipeline copies .d.ts files alongside JavaScript outputs. Mismatched type declarations cause silent failures, especially when switching between bundler and node16 resolution modes.
Publishing Workflows & Provenance Security
Modern CI/CD pipelines must automate dual-format generation, registry publishing, and cryptographic verification. Build scripts should compile ESM and CJS outputs in parallel, verify artifact integrity, and attach provenance metadata before publishing. npm and pnpm handle registry workflows similarly, but pnpm’s strict lockfile management reduces dependency drift during CI runs.
Sigstore and Cosign enable transparent, cryptographic package signing. Provenance attestations verify build origin, compiler versions, and dependency states. This meets enterprise security compliance and prevents supply chain tampering.
#!/bin/bash
# Automated build and publish pipeline
npm run build
npm pack
cosign sign-blob --yes dist/package.tgz
npm publish --provenance --access public
Always publish with --provenance enabled. This generates an immutable attestation linking the published tarball to the exact CI workflow run, ensuring verifiable integrity for all consumers.
Future-Proofing Package Architectures
Legacy fields like main, module, and browser are entering deprecation cycles as the ecosystem standardizes on exports. Native ESM adoption is accelerating. Node.js and modern browsers prioritize static imports over legacy wrappers. Maintaining dual compatibility requires proactive cross-runtime testing and automated compatibility matrices.
Monitor TC39 proposals, Node.js LTS release notes, and bundler changelogs to anticipate breaking changes. Establish migration paths that gradually phase out CJS fallbacks while preserving backward compatibility. Long-term architectural strategies are outlined in Future-Proofing Package Architectures.
Prioritize strict mode compliance and eliminate implicit globals. Enforce explicit file extensions across all imports. These practices ensure seamless transitions as the JavaScript ecosystem converges on a unified, standards-based module resolution model.
Frequently Asked Questions
Can I safely publish both ESM and CJS formats in a single package?
Yes, by using the exports field with explicit import and require conditions, you can safely route consumers to the correct format while avoiding the dual-package hazard.
How does TypeScript 5+ handle dual module resolution?
TypeScript 5+ introduces moduleResolution: bundler and node16/nodeNext modes, which strictly follow Node.js conditional exports and align with modern bundler behavior.
Is sigstore required for npm/pnpm publishing?
While not strictly mandatory, sigstore/cosign provides cryptographic provenance that verifies package origin and build integrity, increasingly expected by enterprise consumers.
Why is the exports field preferred over main and module?
The exports field enforces strict path resolution, prevents unauthorized file access, and supports conditional routing for different environments and module formats.