On this page
A module-system error often looks unrelated to the change that caused it. A package upgrade produces an unexpected token, a configuration file loses access to require, or a default import becomes an object. These failures usually come from mixing assumptions about how JavaScript files are classified, loaded, and connected.
Establish the module boundary first
ES modules, usually abbreviated ESM, are JavaScript's standardized module format. CommonJS is the older module format associated with Node.js. Both organize code into files with explicit dependencies, but their execution models and interoperability rules differ. For new browser-facing code, ESM is the natural baseline. Existing Node applications may have good reasons to retain CommonJS temporarily.
Start a diagnosis with package.json rather than rewriting imports. Paste its contents into the JSON formatter to inspect the type, exports, main, and scripts fields clearly. Formatting catches malformed JSON, not incorrect module semantics, so compare the result with the actual files and execution command.
Record the Node version, package boundary, file extensions, and whether a bundler runs before execution. A development server can hide compatibility problems that appear when Node executes the published package directly. Keep related language references in the JavaScript and TypeScript category, but make the deployed runtime your final authority.
Understand exports, bindings, and execution
CommonJS exposes a module through module.exports and loads dependencies with require. Its conventional loading path is synchronous. ES modules declare imports and exports with syntax that tooling can analyze before execution. Their imported values are live bindings, not snapshots copied into local variables.
// counter.cjs
let count = 0;
module.exports = {
increment() { count += 1; },
getCount() { return count; }
};
// counter.mjs
export let count = 0;
export function increment() {
count += 1;
}
// app.mjs
import { count, increment } from "./counter.mjs";
increment();
console.log(count); // 1An importer cannot assign to count, although the exporting module can update it. In CommonJS, destructuring a primitive property returned by require captures that property's current value; it does not create an ESM-style binding.
Static imports belong at module scope. Use dynamic import when a dependency is conditional or expensive enough to defer. It returns a promise resolving to a module namespace object and is available from CommonJS as well as ESM. Module-level execution normally happens once per resolved module identity, so shared mutable state can leak between callers or tests.
The MDN module guide explains the language model. Remember that static analysis enables optimization; it does not guarantee that a bundler removes every unused export, especially when modules have side effects.
Make Node's file classification explicit
Within a package, a type value of module makes ordinary .js files ES modules; commonjs makes them CommonJS. The .mjs extension explicitly selects ESM, while .cjs explicitly selects CommonJS. Prefer those clear signals over relying on version-dependent syntax detection for ambiguous files.
{
"name": "report-utils",
"type": "module",
"exports": "./src/index.js"
}In Node's native ESM resolver, relative imports generally require the file extension. Write ./format.js instead of assuming ./format or a directory index will resolve. Bare package specifiers follow package resolution rules instead; do not mechanically append .js to every import string.
Browser modules use URLs and need a module script entry point. Bare package names require an import map or a build tool that resolves them. Node package exports are not a browser import map, even though both help define dependency boundaries.
ESM does not provide CommonJS globals such as require or __dirname. Use import.meta.url and URL-based file handling where appropriate. Consult Node's ESM documentation for the exact behavior supported by your minimum runtime. Explicit extensions and an explicit package type make debugging much less dependent on hidden defaults.
Cross the interoperability boundary deliberately
Importing CommonJS from ESM commonly works best through a default import representing module.exports. Node may infer some named exports through static analysis, but that inference is not a reliable substitute for the package's documented interface. A CommonJS module that assigns properties dynamically is especially likely to expose surprises.
// bridge.cjs
async function loadFormatter() {
const { format } = await import("./format.mjs");
return format;
}
module.exports = { loadFormatter };Loading ESM with require is runtime-dependent. Recent Node versions support some synchronous ESM graphs, but top-level await and older supported versions complicate that route. Dynamic import is a clearer bridge when an asynchronous boundary is acceptable. Check the CommonJS documentation before relying on newer interop behavior.
For a package supporting both import and require consumers, conditional exports can route callers to separate builds. That creates maintenance obligations: both entry points need tests, declarations, and equivalent behavior. Loading both builds can also create two instances of state that callers expected to be a singleton.
Do not publish a dual package just because it feels comprehensive. A single ESM distribution, a documented minimum runtime, and an explicit migration note may be simpler and safer for the intended audience.
Migrate a small execution path and test it
Treat TypeScript configuration as part of the runtime contract. Node-oriented projects commonly use NodeNext module and resolution settings, while bundler-driven projects may need bundler resolution. A compiler accepting an import does not prove that the emitted JavaScript will run directly in Node.
Start with a leaf module, then migrate its callers. Replace export assignments intentionally rather than performing a blind text substitution. Audit test runners, command-line entry points, configuration files, and scripts; a working application bundle does not exercise all those execution paths.
Use this migration checklist:
- Declare the minimum Node version and intended package type.
- Rename configuration files that must remain CommonJS to .cjs.
- Add runtime-correct relative extensions to ESM imports.
- Replace CommonJS globals and conditional require calls deliberately.
- Test the published package from a separate consumer directory.
- Exercise startup, dynamic imports, and any top-level asynchronous work.
Preserve the lockfile and review dependency upgrades separately from syntax changes. The solo Git workflow guide offers a useful approach to keeping these changes reviewable. A small compatibility fixture is often more informative than a large mocked unit suite: import the real entry point, call one exported function, and verify its output under every supported runtime.
Key takeaways
- Choose one primary format and document the runtime that executes it.
- Understand live bindings, synchronous loading, and dynamic import boundaries.
- Make file classification and package entry points explicit.
- Treat interop as a tested compatibility layer, not an automatic conversion.
- Verify emitted and published JavaScript, not just editor diagnostics.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor