ESM default via package.json type module in mixed CommonJS application
0 reputation · 10 Feb 2022, 07:57 UTC
0 reputation · 10 Feb 2022, 07:57 UTC
Goal is to migrate a small Node.js application from CommonJS to ECMAScript Modules without service interruption. The codebase currently relies on .js files interpreted as CommonJS and on CommonJS-specific loading semantics.
A documented configuration transition is setting package.json "type" to "module", which changes the default interpretation of .js files to ESM. Node.js supports dual-module loading, with ESM able to import CJS, while CJS can only load ESM via dynamic import(). ESM enables top-level await and enforces strict mode by default, and __dirname and __filename are not provided.
The unresolved decision concerns the compatibility boundary for existing consumers and internal files during an incremental change.
Which file naming and package.json configuration preserves import paths for existing CommonJS consumers while allowing new ESM files? How does the static import graph of ESM interact with circular dependencies that were previously tolerated under CommonJS? What is the documented boundary for reconstructing path globals in ESM without breaking mixed loading?
34775 reputation · 10 Feb 2022, 10:02 UTC
To keep existing CommonJS consumers working while adding ESM, do not change the root package.json "type" to "module" until the legacy CommonJS files are isolated. The safest incremental boundary is:
ESM static linking changes circular dependencies. Previously tolerated CommonJS cycles where require returned a partially built module.exports will often surface as undefined bindings or ReferenceError in ESM because imports are live bindings resolved before module execution.
Path globals in ESM are reconstructed per module with import.meta.url. That reconstruction is module-local and does not affect CommonJS resolution.
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
This is ESM-only and module-relative.Circular imports in ESM are evaluated in a defined order with live bindings. Accessing an imported binding before the exporting module has executed its top-level code yields undefined or a temporal dead zone error. CommonJS require executes the module at call time and returns the current value of module.exports, which allowed many cycles to appear to work. The same cycle in ESM is likely to break.
node --version
# ESM path globals
node new-esm.mjs
# CJS still loads
node -e "require('./legacy.cjs')"One missing diagnostic that changes the recommendation: are the existing CommonJS consumers internal require() calls in your repo, or external packages that import your package by name? If external, a nested package.json type boundary is required and you cannot rely on renaming files alone.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.