Node.js dapat menjalankan JavaScript melalui dua sistem modul: ECMAScript modules (ESM) dan CommonJS. Nama file menjadi salah satu sinyal yang menentukan sistem mana yang dipakai. Itu sebabnya file seperti postcss.config.mjs sering muncul di project meskipun sebagian besar source file tetap menggunakan ekstensi .js.

Hal yang penting: .mjs bukan bahasa JavaScript yang berbeda. Ekstensi ini merupakan instruksi eksplisit kepada Node.js untuk mem-parse dan memuat file sebagai ES module.

.mjs dan .cjs adalah penanda modul eksplisit

Node.js memperlakukan dua ekstensi ini tanpa ambiguitas:

file.mjs -> ES module
file.cjs -> CommonJS

File .mjs selalu dimuat sebagai ESM tanpa bergantung pada package.json terdekat. Sebaliknya, file .cjs selalu dimuat sebagai CommonJS tanpa bergantung pada nilai "type" di package tersebut.

Ekstensi ini berguna ketika satu file perlu memakai format modul yang berbeda dari file lain dalam package yang sama.

ES module dapat memakai sintaks import dan export statis:

// math.mjs
export function add(a, b) {
  return a + b;
}
// app.mjs
import { add } from './math.mjs';

console.log(add(2, 3));

File CommonJS memakai binding CommonJS:

// math.cjs
function add(a, b) {
  return a + b;
}

module.exports = { add };
// app.cjs
const { add } = require('./math.cjs');

console.log(add(2, 3));

Ekstensi memberi tahu Node.js semantik modul yang harus dipakai sebelum file dievaluasi.

package.json mengatur file .js biasa

Tidak perlu memakai .mjs untuk semua file jika seluruh package menggunakan ESM. Node.js menyediakan field package.json untuk menentukan interpretasi file .js:

{
  "type": "module"
}

Dengan konfigurasi tersebut, file .js di dalam package diperlakukan sebagai ES module:

// math.js
export function add(a, b) {
  return a + b;
}

Tanpa mengubah source code, mengganti konfigurasi package menjadi:

{
  "type": "commonjs"
}

membuat file .js biasa diperlakukan sebagai CommonJS.

Field "type" menentukan format modul default sebuah package. Ekstensi eksplisit tetap dapat mengesampingkan default tersebut:

"type": "module"
  .js  -> ES module
  .mjs -> ES module
  .cjs -> CommonJS

"type": "commonjs"
  .js  -> CommonJS
  .mjs -> ES module
  .cjs -> CommonJS

Pola ini memungkinkan project yang sebagian besar memakai ESM tetap mempertahankan satu configuration file CommonJS lama, atau sebaliknya.

package.json terdekat membentuk batas

Field "type" bukan switch untuk seluruh repository. Saat menangani file .js, Node.js mencari parent package.json terdekat yang mengontrol file tersebut.

Nested package dapat memakai default yang berbeda:

project/
├── package.json        # "type": "module"
├── src/
│   └── app.js          # ES module
└── legacy/
    ├── package.json    # "type": "commonjs"
    └── worker.js       # CommonJS

Batas ini penting pada monorepo, test fixture, build tooling, dan embedded package. Memindahkan file .js melewati batas package dapat mengubah interpretasi modulnya meskipun isi file sama sekali tidak berubah.

File .mjs dan .cjs tidak bergantung pada pencarian tersebut untuk menentukan format modul.

File .js ambigu dapat memicu deteksi sintaks

Ada penjelasan singkat yang sering dipakai: .js berarti CommonJS kecuali package.json berisi "type": "module". Pada Node.js modern, penjelasan itu belum lengkap.

Ketika file .js tidak memiliki penanda modul eksplisit, Node.js dapat memeriksa source yang ambigu untuk mencari sintaks yang hanya valid sebagai ESM. Static import, export, import.meta, dan top-level await termasuk sintaks yang dapat membuat runtime mengklasifikasikan file sebagai ES module.

Contohnya:

// ambiguous.js
export const port = 3000;

Jika tidak ada field "type" yang mengontrol file tersebut, Node.js modern dapat mengenali sintaks khusus ESM dan memuat file sesuai format itu.

Tetap lebih jelas jika package mendeklarasikan "type": "module" atau "type": "commonjs". Dengan begitu, format yang dimaksud terlihat langsung oleh Node.js maupun tooling lain tanpa bergantung pada deteksi sintaks.

Dynamic import tidak mengubah file menjadi ESM

Ekspresi import() tersedia di kedua sistem modul. Keberadaannya sendiri tidak membuat file CommonJS berubah menjadi ES module.

File CommonJS berikut valid:

// loader.cjs
async function loadPlugin() {
  const plugin = await import('./plugin.mjs');
  return plugin;
}

module.exports = { loadPlugin };

Perbedaannya penting saat membaca source code. Bentuk statis import ... from ... adalah sintaks ESM, sedangkan ekspresi dinamis import() juga dapat dipanggil dari CommonJS.

Import relatif ESM memakai nama file eksplisit

Resolusi ESM lebih ketat dibanding perilaku klasik require() pada CommonJS untuk relative path. Import relatif ESM biasanya menyertakan ekstensi file:

import { add } from './math.js';

Jika hanya menulis:

import { add } from './math';

ESM resolver Node.js tidak otomatis mencoba ./math.js, ./math.json, dan kandidat lain seperti yang dapat dilakukan resolusi CommonJS tradisional.

Ini salah satu alasan migrasi CommonJS ke ESM bisa gagal meskipun require() sudah diganti menjadi import: sintaks modul dan resolusi modul adalah dua bagian perubahan yang berbeda.

.mjs berguna untuk pengecualian lokal

Untuk aplikasi Node.js baru yang sepenuhnya memakai ESM, deklarasi di level package biasanya lebih rapi:

{
  "type": "module"
}

Source file kemudian tetap dapat memakai ekstensi .js yang umum.

Ekstensi .mjs tetap berguna ketika format modul harus eksplisit pada batas file. Contohnya adalah script mandiri di luar konfigurasi package, configuration file ESM di dalam package CommonJS, atau batas interoperabilitas kecil selama proses migrasi.

Peran sebaliknya dimiliki .cjs: ekstensi ini menandai file CommonJS meskipun default package adalah ESM.

Ekstensi merupakan bagian dari semantik runtime

Perbedaan .mjs, .cjs, dan .js bukan sekadar penamaan file di Node.js. Ekstensi dapat menentukan parser goal, binding modul yang tersedia, serta jalur resolusi yang digunakan runtime.

Model ringkasnya:

.mjs -> always ESM
.cjs -> always CommonJS
.js  -> package.json "type" first; ambiguous input may be syntax-detected

Model ini juga menjelaskan mengapa mengganti nama file dapat mengubah perilaku tanpa mengubah satu baris JavaScript pun. Di Node.js, nama file dan batas package merupakan bagian dari kontrak modul.