CommonJS lahir dari komunitas Node.js, tetapi JavaScript akhirnya mengadopsi standar module resmi bernama ES Modules (ESM). Sintaksnya — import dan export — kini menjadi bahasa pengantar seluruh ekosistem modern: dokumentasi terbaru, framework baru, dan proyek yang kita tulis di Course ini semuanya memakai ESM. Kabar baiknya, konsep yang sudah kita kuasai di Chapter CommonJS — ekspor nilai, impor nilai, cache — berlaku sama persis; yang berubah hanya penulisannya. Chapter ini membahas seluruh perangkat ESM di Node.js: export bernama dan default, re-export, impor JSON, hingga import() dinamis dengan top-level await.

Mengaktifkan ES Modules di Node.js #

Node.js menentukan format module sebuah berkas dari ekstensinya. Berkas .mjs selalu diperlakukan sebagai ESM, berkebalikan dengan .cjs yang selalu diperlakukan sebagai CommonJS — dua ekstensi inilah yang dipakai konsisten di seluruh Course agar format selalu jelas tanpa konfigurasi. Berkas .js bersifat mengikuti aturan pemiliknya:

json package.json
{
  "type": "module"
}

Field type di package.json mengatur format seluruh berkas .js dalam proyek: nilai "module" menjadikan .js sebagai ESM, nilai "commonjs" (atau field yang dikosongkan) menjadikannya CommonJS. Karena itu proyek ESM modern umumnya cukup memakai .js biasa, sementara ekstensi eksplisit .mjs dan .cjs berguna saat satu proyek memuat kedua format sekaligus. Pembahasan lengkap field package.json menanti pada Chapter khusus; sekarang kita fokus pada sintaks ESM.

export Bernama dan export default #

ESM menyediakan dua gaya ekspor. Named export memberi nama pada setiap nilai yang dibagikan — sejumlah berapa pun per module — sementara default export mengekspor satu nilai utama tanpa nama tetap. Module sapa.mjs berikut memakai keduanya sekaligus agar perbedaannya terlihat:

js
export function sapa(nama) {
  return `Halo, ${nama}!`;
}

export function sapaFormal(nama, gelar) {
  return `Selamat datang, ${gelar} ${nama}.`;
}

export default function pamit(nama) {
  return `Sampai jumpa, ${nama}!`;
}

Module pemakai memuatnya dengan berbagai bentuk import:

js
import pamit, { sapa, sapaFormal as formal } from "./sapa.mjs";
import * as modulSapa from "./sapa.mjs";

console.log(sapa("Budi"));
console.log(formal("Siti", "Ibu"));
console.log(pamit("Dewi"));
console.log("isi namespace:", Object.keys(modulSapa).sort().join(", "));

Output yang Dihasilkan:

text
Halo, Budi!
Selamat datang, Ibu Siti.
Sampai jumpa, Dewi!
isi namespace: default, sapa, sapaFormal

Mari kita telaah baris-baris kode di atas:

  1. Baris 1 (import pamit, { ... }): nilai default ditulis tanpa kurung kurawal dan boleh diberi nama bebas — di sini pamit. Named export ditulis di dalam {}, dan sapaFormal as formal mengganti nama lokal menjadi formal tanpa mengubah nama ekspornya.
  2. Baris 2 (import * as modulSapa): seluruh ekspor dikemas menjadi satu namespace object. Hasil Object.keys() memperlihatkan named export sapa dan sapaFormal berdampingan dengan properti default yang memuat nilai default export.
  3. Specifier "./sapa.mjs": berbeda dengan require(), import statis di Node.js tidak menebak ekstensi berkas — path harus ditulis lengkap sampai .mjs. Alasannya kita bedah pada Chapter Resolusi Module.

Mengekspor Ulang dengan export … from #

Module perantara sering berguna: ia mengumpulkan ekspor dari beberapa berkas agar pemakai cukup mengimpor dari satu tempat. Sintaks export ... from memindahkan ekspor tanpa harus mendeklarasikannya lebih dulu:

js
export { sapa, sapaFormal } from "./sapa.mjs";
export { default as pamit } from "./sapa.mjs";

Baris pertama meneruskan dua named export apa adanya. Baris kedua meneruskan default export sekaligus mengubahnya menjadi named export bernama pamit — perubahan bentuk semacam ini membuat nilai default tetap nyaman dipakai di berantai. Module pemakai tinggal mengimpor dari perantara:

js
import { sapa, pamit } from "./kumpulan.mjs";

console.log(sapa("Budi"));
console.log(pamit("Dewi"));

Output yang Dihasilkan:

text
Halo, Budi!
Sampai jumpa, Dewi!

Pola ini dikenal sebagai barrel dan dipakai hampir semua package besar: berkas index di folder bersangkutan mengumpulkan ekspor dari berbagai berkas internal, lalu pemakai package menulis satu baris import saja. Nilai pamit di contoh kita bekerja sama persis dengan pamit di Chapter sebelumnya karena keduanya menunjuk function yang sama — export ... from hanya meneruskan referensi, bukan menyalin nilai.

Mengimpor JSON dengan Atribut with #

Berkas JSON adalah format konfigurasi paling umum di Node.js. ESM dapat mengimpor JSON secara langsung, tetapi wajib menyertakan atribut with { type: "json" } sebagai penegas bahwa berkas yang dimuat memang berformat JSON. Kita siapkan satu berkas konfigurasi:

json
{
  "namaAplikasi": "Pencatat Tugas",
  "versi": "1.0.0",
  "bahasa": "id",
  "penulis": {
    "nama": "Dewi",
    "email": "[email protected]"
  }
}

Impor default menghasilkan object JavaScript yang siap dipakai:

js
import konfigurasi from "./konfigurasi.json" with { type: "json" };

console.log(`${konfigurasi.namaAplikasi} v${konfigurasi.versi}`);
console.log(`penulis: ${konfigurasi.penulis.nama} <${konfigurasi.penulis.email}>`);
console.log(`jumlah kunci tingkat atas: ${Object.keys(konfigurasi).length}`);

Output yang Dihasilkan:

text
Pencatat Tugas v1.0.0
penulis: Dewi <[email protected]>
jumlah kunci tingkat atas: 4

Object bersarang seperti penulis ikut terbentuk apa adanya, dan seluruh nilai berupa data JavaScript biasa hasil parsing — angka menjadi number, teks menjadi string, tidak ada function atau kode yang ikut dieksekusi dari sebuah berkas JSON. Atribut with sendiri adalah sintaks standar JavaScript untuk memberi metadata pada impor; Node.js memakainya untuk memvalidasi format berkas sebelum mengevaluasinya, sehingga berkas yang bukan JSON tertahan lebih awal.

import() Dinamis dan Top-level await #

Seluruh import sejauh ini bersifat statis: specifier harus berupa string harfiah dan dimuat saat program dimulai. Saat kita perlu memilih berkas saat program berjalan — misalnya memuat perintah CLI berat hanya ketika dipanggil — ESM menyediakan import() dinamis yang mengembalikan Promise namespace object:

js
const modeFormal = true;
const berkasTujuan = modeFormal ? "./sapa.mjs" : "./kumpulan.mjs";

const modul = await import(berkasTujuan);

console.log("memuat:", berkasTujuan);
console.log("tipe sapa:", typeof modul.sapa);
console.log(modul.sapa("Budi"));

Output yang Dihasilkan:

text
memuat: ./sapa.mjs
tipe sapa: function
Halo, Budi!

Dua hal baru terjadi di berkas singkat ini. Pertama, import(berkasTujuan) menerima variable — hal yang mustahil pada import statis — sehingga berkas yang dimuat bisa dipilih dari kondisi runtime. Kedua, kata kunci await dipakai langsung di tingkat atas berkas tanpa pembungkus async function; kemampuan ini disebut top-level await dan hanya ada di ESM. CommonJS tidak memilikinya, dan itulah salah satu alasan require() tidak pernah bisa menunggu Promise.

Hasil import() adalah namespace object yang sama bentuknya dengan import * as — properti sapa, sapaFormal, dan default semuanya tersedia. Ketika module yang dimuat ternyata tidak ada, Promise tersebut berakhir dengan error ERR_MODULE_NOT_FOUND yang bisa kita tangkap dengan try/catch — perilaku ini kita buktikan pada Chapter Resolusi Module.

Kedua sistem module kini kita kuasai: require() untuk warisan CommonJS, import untuk standar ESM. Pertanyaan berikutnya adalah bagaimana tepatnya Node.js mengubah satu baris specifier seperti "./sapa.mjs" atau "express" menjadi berkas di disk — algoritma di baliknya adalah materi Chapter Resolusi Module.