Skip to the solution
MediumBackend7 min read

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

931 views

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.

  • nodejs
  • esm
  • commonjs
  • modules

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 8 sections

Read the code

JavaScriptReal 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

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 58 of 152 in Node.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Node.js questions
Up nextMedium

What is top-level await and what are its caveats in Node.js ESM?

Continue