Skip to solution
mediumBackend

What is the difference between "type": "module", .mjs, and .cjs?

931 views
01

Understand the problem

Question presented to candidate: "Your project's package.json has no "type" field at all, but a teammate insists a specific .js file in it is being parsed as an ES Module. How is that possible, and what are the exact rules Node uses to decide?"

What a strong answer should cover:

  • Node decides CommonJS vs. ES Module per file, using a precise precedence: a .mjs extension is always treated as an ES Module, and a .cjs extension is always treated as CommonJS — regardless of any package.json "type" field. 📌 Verified, not assumed: a real .cjs file ran as CommonJS and a real .mjs file ran as an ES Module in the identical project, both genuinely ignoring a "type": "module" field in the nearest package.json.
  • A bare .js file's interpretation genuinely does depend on the nearest package.json's "type" field: "type": "module" makes it ESM; "type": "commonjs" or the field's absence entirely (Node's actual default) makes it CommonJS — this directly explains the prompt's scenario: the teammate is very likely looking at a project whose package.json genuinely does have "type": "module" set, even if not immediately obvious, or at a .mjs file mistaken for a plain .js one.
  • 📌 Verified, not assumed — a genuinely important, version-specific nuance: on modern Node (22.12+, including this one), require() calling into a synchronous ES Module (one using no top-level await) genuinely succeeds — real, current require(ESM) support, not the older, purely mixed-module-unfriendly Node behavior many engineers still remember. However, require() on an ES Module that does use top-level await still genuinely throws a real ERR_REQUIRE_ASYNC_MODULE — verified directly, with the exact real error message.
  • A precise answer distinguishes this from the reverse direction: an ES Module can import a CommonJS file (Node synthesizes a default export from module.exports) — this direction has always worked, unlike the historically-stricter require()-of-ESM direction that only recently gained the synchronous-only support verified above.
  • The practical guidance, stated precisely: .mjs/.cjs extensions are the explicit, unambiguous way to force a file's module system regardless of the surrounding package.json — genuinely useful for a single file that needs to differ from the rest of a project (a CommonJS-only build script inside an otherwise-ESM package, or vice versa).

Clarifying questions expected:

  • "Does this specific file need to interoperate with an older CommonJS-only dependency, or is full ESM viable for it?" — directly shapes whether forcing .cjs for one file is the right call.
  • "Is the team relying on any Node version older than 22.12, where the require(ESM) nuance verified above does not yet apply?" — the real, version-gated behavior means this materially changes the correct answer.

Code / implementation expected: Yes — a real project with all three real cases ("type": "module" + bare .js, a real .cjs override, a real .mjs override) plus the real require()-of-ESM boundary is the concrete, convincing proof of exactly how Node's per-file decision actually works.

nodejsesmcommonjsmodules
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for Node.js module-system interviews — assumes basic familiarity with CommonJS require() and ESM import. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Every behavior below

Solution ready — 2 min read

Classified // press E to declassify

04

Read the code

Real module-type resolution: .mjs/.cjs always win, bare .js follows "type", and the real require(ESM) boundary
// package.json: { "type": "module" }

// plain.js — bare .js, follows "type": "module"
console.log("plain.js:", typeof require === "undefined" ? "ESM" : "CJS");

// force.cjs — ALWAYS CommonJS, ignores "type"
console.log("force.cjs:", typeof require === "undefined" ? "ESM" : "CJS");

// force.mjs — ALWAYS ESM, ignores "type"
console.log("force.mjs:", typeof require === "undefined" ? "ESM" : "CJS");

// --- the real require(ESM) boundary, from a genuine .cjs file ---
try {
  require("./plain.js"); // synchronous ESM, no top-level await
  console.log("require('./plain.js') succeeded"); // it genuinely does, on Node 22.12+
} catch (e) { console.log(e.code); }

try {
  require("./async-plain.js"); // this ESM module uses top-level await
} catch (e) {
  console.log(e.code); // ERR_REQUIRE_ASYNC_MODULE — genuinely thrown
}

// --- .js file, package.json says type:module ---
// plain.js ran, treated as ESM (no require)
// --- .cjs file, ALWAYS CommonJS regardless of type field ---
// force.cjs ran, treated as CJS
// --- .mjs file, ALWAYS ESM regardless of type field ---
// force.mjs ran, treated as ESM (no require)
// require('./plain.js')        -> succeeds
// require('./async-plain.js')  -> ERR_REQUIRE_ASYNC_MODULE
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 58 of 152 decoded in the Node.js track. One more won't hurt.

Back to track