Node.js ESM & CommonJS
How Node decides: CommonJS or ESM
Node classifies every file as one system or the other before it runs a line, and the rules apply in priority order:
- Extension wins outright. A
.mjsfile is always ESM, a.cjsfile is always CommonJS, whatever else is configured. - A
.jsfile defers to the nearest parentpackage.json."type": "module"makes it ESM;"type": "commonjs"or notypefield at all makes it CommonJS (absent is the default). - With no
typefield and ambiguous source, Node parses the file: if a CommonJS parse fails and it seesimport,export, or top-levelawait, it re-reads the file as ESM.
Wrong "I set "type": "module", so every file in the package is ESM." The type field only governs .js files. A .cjs file next to it still loads as CommonJS and still has require, because the extension overrides type.
What changes inside an ESM file
An ESM file is always in strict mode, and the CommonJS scope variables are gone: no require, no module, no exports, no __dirname or __filename. Reading any of them throws a ReferenceError. In return you get import and export, import.meta, and top-level await (you can await at module scope, and importers block on that module until it settles). At the top level this is undefined in ESM, where in CommonJS it is module.exports.
Recreating __dirname and require in ESM
The file location and the require function have direct replacements:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { createRequire } from "node:module";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Current Node exposes these two directly:
const here = import.meta.dirname; // same value as __dirname
const self = import.meta.filename; // same value as __filename
const require = createRequire(import.meta.url);
const data = require("./data.json"); // pull in JSON or a CJS file the old way
Importing CommonJS from ESM
An ESM file can always import a CommonJS one. The default import is the entire module.exports value:
// legacy.cjs
module.exports = { add: (a, b) => a + b };
// app.mjs
import legacy from "./legacy.cjs"; // default = the whole module.exports
legacy.add(2, 3); // 5
Named imports also work when Node can detect the export statically: it scans the CommonJS source without running it and synthesizes bindings for patterns like exports.foo =. Exports assigned dynamically are invisible to that scan, so the safe habit is to take the default import and destructure from it at runtime: const { add } = legacy.
require() of an ESM module: what current Node does
For years, require() of any ESM file threw an error whose code is ERR_REQUIRE_ESM, and the only bridge from CommonJS to ESM was dynamic import(). That changed. Require-of-ESM is on by default since Node 23.0, Node 22.12 (the first LTS to ship it), and Node 20.19, and it left experimental status in 25.4.
// util.mjs: export const add = (a, b) => a + b;
const util = require("./util.mjs"); // module namespace object
util.add(2, 3); // 5
require() now evaluates a fully synchronous ESM module and returns its namespace object, the same shape import * as produces. The catch is synchrony. If the target module or anything in its import graph uses top-level await, it cannot be evaluated synchronously, and require() throws a different error whose code is ERR_REQUIRE_ASYNC_MODULE. For those, dynamic import() remains the tool, because it is async and can wait for the graph to settle. Detect the capability at runtime with process.features.require_module.
Wrong "require() of an ESM package always throws ERR_REQUIRE_ESM." True before 22.12. Now it throws only for asynchronous module graphs, and even then the code is ERR_REQUIRE_ASYNC_MODULE, not ERR_REQUIRE_ESM.
The exports field encapsulates a package
Once a package.json has an exports field it does two jobs: it declares the public entry points, and it blocks every path not listed, including files that physically exist on disk. Deep-importing an unlisted path fails with an error whose code is ERR_PACKAGE_PATH_NOT_EXPORTED.
{
"exports": {
".": "./index.js",
"./helpers": "./src/helpers.js"
}
}
import "pkg/helpers" resolves; import "pkg/src/internal.js" throws, even though the file is right there. That block is what lets a library rearrange its internals without breaking consumers. Two more consequences: exports takes precedence over the legacy main field (ship both only for old-Node back-compat), and conditional keys are matched in object order with the first match winning, so default goes last.
{
"exports": {
"import": "./index.mjs",
"require": "./index.cjs",
"default": "./index.js"
}
}
Node picks import when the caller used import or import(), require when it used require(). Putting default first would shadow the rest, since resolution stops at the first key that matches.
Why named imports from CommonJS sometimes fail
The default import of a CommonJS module is always its module.exports. Named imports are a convenience Node synthesizes by statically scanning the CommonJS source (cjs-module-lexer) for assignment patterns, without running it. Anything the scan cannot see produces no named binding: computed keys, exports assigned inside a branch, Object.defineProperty, and re-exports it cannot follow.
// dynamic.cjs
const key = "add";
module.exports[key] = (a, b) => a + b; // computed, not statically visible
// app.mjs
import { add } from "./dynamic.cjs"; // fails at link time: no such named export
import pkg from "./dynamic.cjs"; // default always works
pkg.add(2, 3); // 5
The named import fails with a SyntaxError reporting no matching export before the module even runs. The reliable pattern is the default import destructured at runtime.
The dual-package hazard
When a library ships both builds through conditional exports ("import": "./index.mjs", "require": "./index.cjs"), one process can load both: your ESM code imports it while a transitive CommonJS dependency requires it. Node resolves those to two different files and evaluates each once, so you hold two copies with independent state.
const a = require("pkg"); // CJS copy
const { default: b } = await import("pkg"); // ESM copy, a !== b
Module-level singletons duplicate, registries and caches split in two, and instanceof fails for objects that crossed the boundary. The durable fixes are structural: ship one format, or keep the real implementation in one shared file that both entry points re-export, so state is evaluated exactly once. If two builds are unavoidable, keep them stateless.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.