Node.js Module Sistemi: CommonJS ve ES Module, "type", "exports" ve require(esm)
Node.js bir dosyayı uzantısına (.mjs/.cjs) ve en yakın package.json'daki "type" alanına göre CommonJS ya da ES module olarak yükler; Node 26'da require(esm) stable olduğu için iki dünya artık birbirini top-level await dışında sorunsuz yükleyebiliyor. Gerçek hatalarla uzantısız import, ERR_REQUIRE_ASYNC_MODULE, CommonJS'ten named import, exports ile kapatılan yollar, dual package hazard ve Bookstore API'sinde #-alias'lar ile JSON import attribute'ları.
Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS) ile çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için fiyat biçimlendiren küçük bir module yazdık. Klasördeki package.json'da "type": "module" var, yani price.js bir ECMAScript module'ü:
export function formatPrice(amount) {
return `${amount.toFixed(2)} TRY`;
}
Bu fonksiyonu iki dosyadan kullanıyoruz. Birincisi yine bir ECMAScript module'ü (guess.js), ikincisi eski usul bir CommonJS dosyası (guess.cjs). İkisi de dosya adını uzantısız yazıyor:
import { formatPrice } from "./price";
console.log(formatPrice(349.9));
const { formatPrice } = require("./price");
console.log(formatPrice(349.9));
Hangisi çalışır? Bir ipucu: biri ECMAScript module'ü olan bir dosyayı import ile, diğeri require ile yüklüyor. Eski bilgiyle "ECMAScript module'ü require edilemez" diye düşünebilirsin.
node guess.js
node guess.cjs
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/user/bookstore-api/labs/03/price' imported from /home/user/bookstore-api/labs/03/guess.js
Did you mean to import "./price.js"?
349.90 TRY
Tersi oldu. import uzantısız yolu bulamadı; require ise hem uzantıyı kendisi tamamladı hem de bir ECMAScript module'ünü sorunsuz yükledi. İlki ECMAScript module'lerinin çözümleme kuralı: dosya yolları tam yazılır, tahmin yapılmaz. İkincisi Node 22.12'de bayraksız hâle gelen, 25.4'te de deneysel olmaktan çıkan require(esm). Bu yazıda Node'un iki module sistemini, hangisinin ne zaman devreye girdiğini, package.json'daki exports ve imports alanlarını ve Bookstore API'sine eklediğimiz # ile başlayan import'ları anlatıyoruz. Tarayıcıdaki module'ler JavaScript Orta serisinin "JavaScript Module'leri Tarayıcıda" yazısında; burada yalnız Node'a özgü kurallar var.
İki Module Sistemi, Bir Runtime
Node 2009'da çıktığında JavaScript'in standart bir module sistemi yoktu. Node, CommonJS denen bir öneriyi benimsedi: require() ile yükle, module.exports ile dışarı ver. npm ekosisteminin büyük kısmı bu sistemle yazıldı. ECMAScript 2015 ise dile import/export syntax'ını getirdi. Kısaca ESM (ECMAScript Modules; dilin standart module sistemi) denen bu sistem tarayıcılarda da Node'da da çalışır ve bugün yeni kodun varsayılanıdır. Node ikisini de destekliyor; bir dosyanın hangisiyle yükleneceğine şu sırayla karar veriyor:
.mjsher zaman ESM,.cjsher zaman CommonJS..jsdosyası için en yakın üst klasördekipackage.json'a bakılır:"type": "module"ise ESM,"type": "commonjs"ise CommonJS.typealanı hiç yoksa Node kaynağa bakar;import,export,import.metaya da top-levelawaitgörürse dosyayı ESM olarak yükler. Bu syntax detection Node 22.7'den beri varsayılan olarak açık..ts,.mtsve.ctsaynı kurallarla yüklenir; TypeScript tarafını 05'te göreceğiz.
Son maddeye güvenme; detection fazladan bir ayrıştırma demek ve okuyana bir şey söylemez. npm 11'in npm init'te "type": "commonjs"'i açıkça yazmasının nedeni de bu. Bookstore API'sinde "type": "module" kullanıyoruz; CommonJS'e yalnız eski paketlerle konuşurken dokunacağız.
CommonJS: require, module.exports ve require Cache'i
CommonJS'te her dosya bir fonksiyonun içine sarılarak çalıştırılır; require, module, exports, __dirname ve __filename bu fonksiyonun parametreleri olarak gelir. require senkrondur: dosyayı okur, çalıştırır ve module.exports'ta ne varsa döndürür.
// CommonJS: senkron require, module.exports ve module'e özel değişkenler
const path = require("node:path");
let created = 0;
function createCart(customer) {
created++;
return { customer, items: [] };
}
module.exports = { createCart, createdCount: () => created };
console.log("cart.cjs evaluated in", path.basename(__dirname), "as", path.basename(__filename));
const first = require("./cart.cjs");
const second = require("./cart.cjs");
first.createCart("Ada");
first.createCart("Alan");
console.log("same object:", first === second);
console.log("carts created:", second.createdCount());
console.log("cached:", Object.keys(require.cache).map((f) => f.split("/").pop()));
cart.cjs evaluated in 03 as cart.cjs
same object: true
carts created: 2
cached: [ 'cjs-basics.cjs', 'cart.cjs' ]
İki require çağrısı aynı object'i döndürdü ve cart.cjs yalnız bir kez çalıştı. Module'ler ilk yüklemeden sonra require.cache'te tutulur; bu yüzden bir module'deki değişken, process boyunca yaşayan bir singleton gibi davranır. ASP.NET Core'daki DI (dependency injection; bağımlılıkların dışarıdan verilmesi) container'ının singleton lifetime'ına benzer, ama container yok: aynı dosyayı yükleyen herkes aynı instance'ı görür. ESM'de de aynısı geçerli, module'ler bir kez değerlendirilir.
module.exports ile exports arasındaki fark da klasik bir tuzak. exports.x = ... yazmak module.exports object'ine property ekler; exports = { x } yazmak ise yalnız yerel değişkeni değiştirir ve dışarıya hiçbir şey vermez.
ES Module: import, export, import.meta ve Top-Level Await
// ESM: import/export, import.meta ve top-level await
import { readFile } from "node:fs/promises";
import { join } from "node:path";
console.log("dirname:", import.meta.dirname.split("/").pop());
console.log("filename:", import.meta.filename.split("/").pop());
console.log("typeof require:", typeof require);
// Top-level await: async main() sarmalayıcısına gerek yok
const books = JSON.parse(await readFile(join(import.meta.dirname, "books.json"), "utf8"));
console.log("books:", books.map((b) => b.title).join(", "));
dirname: 03
filename: esm-basics.js
typeof require: undefined
books: Dune, The Hobbit
ESM'de require, __dirname ve __filename yok. Yerlerine import.meta.dirname ve import.meta.filename geliyor; ikisi Node 20.11'de eklendi, 24'te deneysel olmaktan çıktı. Eski kodlarda gördüğün fileURLToPath(import.meta.url) dolambacına artık gerek yok. Gerçekten require gerekiyorsa node:module'deki createRequire(import.meta.url) ile bir tane üretebilirsin.
Top-level await, module'ün en üst seviyesinde await kullanmana izin verir; bu module'ü import eden her module, bu bekleme bitene kadar değerlendirilmez. Uygulama başlarken configuration okumak gibi işler için rahat, ama birazdan göreceğimiz gibi bir bedeli var.
ESM'de çözümleme kuralları da daha katı. Relative import'larda uzantı zorunlu (./price.js), klasör import'u (./routes yazıp index.js'i beklemek) yok, yollar URL gibi yorumlanır. Önce Tahmin Et'teki hata mesajı bu yüzden "Did you mean to import ./price.js?" diye öneriyor. CommonJS'teki uzantı tamamlama ise .js, .json ve .node uzantılarını sırayla dener.
require(esm): CommonJS'ten ECMAScript Module'ü Yüklemek
Yıllarca CommonJS'ten bir ESM paketini kullanmanın tek yolu dinamik import() idi; bu da senkron require ile yazılmış bir kodu async'e çevirmek demekti. Node 22.12'de bayraksız gelen, 25.4'te stable olan require(esm) bunu değiştirdi:
// ESM'yi top-level await'li ve await'siz iki durumda require etmek
const price = require("./price.js");
console.log("price.js via require:", price.formatPrice(289.5));
console.log("require_module:", process.features.require_module);
try {
require("./exchange-rates.js");
} catch (err) {
console.log(err.code);
console.log(err.message.split("\n")[0]);
}
// ESM ve top-level await: kurlar yüklenmeden module kullanılamaz
import { setTimeout as delay } from "node:timers/promises";
await delay(10);
export const rates = { EUR: 37.6 };
price.js via require: 289.50 TRY
require_module: true
ERR_REQUIRE_ASYNC_MODULE
require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
Kural basit: require senkron olduğu için, yüklenen ESM grafiğinin hiçbir yerinde top-level await olmamalı. Varsa ERR_REQUIRE_ASYNC_MODULE alırsın ve import()'a dönersin. Hata mesajındaki --experimental-print-required-tla bayrağı, derin bir bağımlılık zincirinde await'in hangi dosyada olduğunu bulmana yarar. Buradan çıkan pratik ders: library yazıyorsan top-level await'ten kaçın, yoksa CommonJS kullanıcılarını dışarıda bırakırsın.
ESM'den CommonJS Kullanmak: Named Import Ne Zaman Çalışır?
Ters yön daha eski ve çoğu zaman sorunsuz: ESM'den bir CommonJS module'ünü import edebilirsin. Ama named import'lar her zaman çalışmaz. Exports'u çalışma sırasında üretilen bir module:
// Exports'u çalışma anında üretilen eski bir CommonJS module'ü
function createPricing(vatRate) {
return {
withVat: (amount) => Math.round(amount * (1 + vatRate) * 100) / 100,
};
}
module.exports = createPricing(0.1);
import { withVat } from "./legacy-pricing.cjs";
console.log(withVat(349.9));
SyntaxError: Named export 'withVat' not found. The requested module './legacy-pricing.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from './legacy-pricing.cjs';
const { withVat } = pkg;
Node, ESM'deki named import'ları bağlamadan önce bilmek zorunda; CommonJS module'ünü çalıştırmadan ne export ettiğini ise kaynak koda bakarak tahmin ediyor. exports.withVat = ... ya da module.exports = { withVat } gibi kalıpları tanır, ama bir fonksiyonun döndürdüğü object'i göremez. Hata mesajı çözümü de veriyor: default import her zaman çalışır, çünkü module.exports'un kendisidir.
import pricing from "./legacy-pricing.cjs";
console.log(pricing.withVat(349.9));
384.89
package.json "exports": Paketin Dışarıya Açtığı Kapı
exports alanı bir paketin hangi dosyalarının dışarıdan import edilebileceğini belirler. Eski main alanı yalnız giriş noktasını söylerdi ve paketin içindeki her dosya paket/lib/x.js diye yüklenebilirdi. exports varsa yalnız listelenen yollar açıktır; .NET'teki internal'ın paket seviyesindeki karşılığı gibi düşün. Bir laboratuvar klasöründe küçük bir paket tanımı yaptım:
{
"name": "bookstore-pricing",
"type": "module",
"exports": {
".": "./price.js",
"./cache": {
"import": "./price-cache.mjs",
"require": "./price-cache.cjs"
}
}
}
exports tanımlı bir paket kendi içinden kendi adıyla import edilebilir (self-reference). Bunu kullanarak paketi node_modules'a kurmadan deneyebiliyoruz:
// Self-reference: paket, "exports" üzerinden kendini adıyla import edebilir
import { formatPrice } from "bookstore-pricing";
console.log(formatPrice(310));
try {
await import("bookstore-pricing/cart.cjs");
} catch (err) {
console.log(err.code);
console.log(err.message.split(" imported from")[0]);
}
310.00 TRY
ERR_PACKAGE_PATH_NOT_EXPORTED
Package subpath './cart.cjs' is not defined by "exports" in /home/user/bookstore-api/labs/03/package.json
./cache girişi conditional export: import ile yüklenirse .mjs, require ile yüklenirse .cjs dosyası seçilir. Koşullar yazıldığı sırayla denenir; TypeScript tipleri için types koşulu en başa, her şeyi yakalayan default ise Node dokümanının da söylediği gibi en sona yazılır.
Dual Package Hazard
Hem CommonJS hem ESM build'i olan paketlere dual package denir. Kullanışlılar, ama bir riskleri var: aynı uygulamada paketin iki kopyası birden yüklenebilir. Fiyat cache'imizin iki build'i birer Map tutuyor. Uygulama ESM tarafında bir fiyat yazıyor, CommonJS ile yazılmış eski bir fatura module'ü ise aynı paketi require ile okuyor:
// Fiyatları cache'ten okuyan bir CommonJS bağımlılığı
const { getPrice } = require("bookstore-pricing/cache");
exports.invoiceLine = (isbn) => `${isbn}: ${getPrice(isbn) ?? "price missing"}`;
import { setPrice, getPrice } from "bookstore-pricing/cache";
import { invoiceLine } from "./invoice.cjs";
setPrice("9780441172719", 349.9);
console.log("ESM side:", getPrice("9780441172719"));
console.log("CJS side:", invoiceLine("9780441172719"));
ESM side: 349.9
CJS side: 9780441172719: price missing
Aynı paket adı, iki ayrı module instance'ı, iki ayrı Map. State tutan, instanceof kontrolü yapan ya da bir singleton'a dayanan her paket bu tuzağa düşebilir. Çözüm yollarından biri state'i tek bir build'de tutup diğerini ona ince bir sarmalayıcı yapmak. require(esm) ile birlikte bir yol daha açıldı: paketi yalnız ESM olarak yayınlamak; CommonJS kullanıcıları da onu require ile yükleyebiliyor.
"imports" ve #-Alias'lar: Bookstore API'sine Uygulamak
exports paketin dışarıya açtığı yollar, imports ise paketin kendi içinde kullandığı kısayollar. Adları # ile başlamak zorunda; bu sayede npm paket adlarıyla çakışmazlar. Bookstore API'sinde ../../services/bookService.js gibi relative yollar yerine bunu kullanıyoruz:
"imports": {
"#routes/*": "./src/routes/*.js",
"#services/*": "./src/services/*.js",
"#db/*": "./src/db/*"
}
Pattern'de uzantıyı hedef tarafa yazdığım için kodda #routes/health yazmak yetiyor; uzantıyı tahmin eden Node değil, eşlemeyi sen tanımladın. 05'te TypeScript'e geçerken bu üç satırda yalnız uzantıyı değiştireceğiz, kod aynı kalacak. Node'un bunları nasıl çözdüğüne import.meta.resolve ile bakabilirsin:
node --input-type=module -e 'console.log(import.meta.resolve("#routes/health")); console.log(import.meta.resolve("#db/books.json"))'
file:///home/user/bookstore-api/src/routes/health.js
file:///home/user/bookstore-api/src/db/books.json
Tanımlanmamış bir alias ise ERR_PACKAGE_IMPORT_NOT_DEFINED verir. Kitap listesini artık bir JSON dosyasından okuyan service ve yeni route şöyle:
// SQL Server yazısına kadar bellekte duran katalog; JSON dosyası başlangıçta bir kez yüklenir
import books from "#db/books.json" with { type: "json" };
export function listBooks() {
return books;
}
export function findBook(isbn) {
return books.find((b) => b.isbn === isbn);
}
import { listBooks } from "#services/bookService";
// GET /api/books
export function handleListBooks(req, res) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(listBooks()));
}
import { createServer } from "node:http";
import { handleHealth } from "#routes/health";
import { handleListBooks } from "#routes/books";
const port = Number(process.env.PORT ?? 3000);
const server = createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
return handleHealth(req, res);
}
if (req.method === "GET" && req.url === "/api/books") {
return handleListBooks(req, res);
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
server.listen(port, () => {
console.log(`Bookstore API listening on http://localhost:${port} (${process.env.NODE_ENV ?? "development"})`);
});
curl http://localhost:3000/api/books
[{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":349.9,"currency":"TRY"},{"isbn":"9780547928227","title":"The Hobbit","author":"J.R.R. Tolkien","price":289.5,"currency":"TRY"},{"isbn":"9780141439600","title":"Middlemarch","author":"George Eliot","price":310,"currency":"TRY"},{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":245,"currency":"TRY"}]
JSON Import Attributes
with { type: "json" } kısmı bir import attribute. ESM'de JSON dosyası import ederken zorunlu; yazmazsan Node dosyayı yüklemez:
TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module "file:///home/user/bookstore-api/labs/03/books.json" needs an import attribute of "type: json"
code: 'ERR_IMPORT_ATTRIBUTE_MISSING'
Bunun nedeni güvenlik: tarayıcıda bir sunucu .json uzantılı bir URL'den JavaScript döndürebilir; attribute, beklenen içeriğin veri olduğunu açıkça söyler ve Node aynı kuralı uygular. JSON module'leri de diğerleri gibi bir kez yüklenir ve aynı object paylaşılır; bu object'i değiştirirsen bütün uygulama değişikliği görür.
Üretimde Dikkat
Module yükleme başlangıç maliyetidir. Büyük bir bağımlılık ağacını açılışta yüklemek, container'ın hazır olma süresini uzatır. Nadiren kullanılan ağır bir module'ü (örneğin PDF üretimi) dinamik import() ile ihtiyaç anında yüklemek bu süreyi kısaltır.
Top-level await'i yalnız uygulamanın giriş noktasında kullan. Paylaşılan bir module'de kullanırsan, onu require eden her şey ERR_REQUIRE_ASYNC_MODULE ile kırılır ve import eden her module o beklemeyi miras alır.
Module seviyesindeki state process ömrü boyunca yaşar ve bütün request'ler arasında paylaşılır. Request'e özel bir değeri (kullanıcı, correlation id) module seviyesindeki bir değişkende tutmak, eşzamanlı request'lerin birbirinin verisini görmesine yol açar. Request'e özel context için doğru araç AsyncLocalStorage; 07'de kuracağız.
Production build'inde "type" alanının ve dosya uzantılarının değişmediğinden emin ol. Bir bundler ya da kopyalama adımı package.json'u atlarsa .js dosyaların CommonJS olarak yüklenmeye çalışılır ve uygulama ilk import satırında durur.
Sık Düşülen Tuzaklar
ESM'de uzantısız relative import. import "./price" çalışmaz; ./price.js yaz. TypeScript'te bile, 05'te göreceğimiz gibi, Node'un okuduğu uzantıyı yazarsın.
CommonJS paketini named import ile almak. Paketin exports'u dinamik üretiliyorsa named import SyntaxError verir; default import ile alıp içinden seç.
exports eklemenin breaking change olduğunu unutmak. Bir paketin package.json'una exports eklemek, kullanıcıların doğrudan import ettiği iç dosyaları bir anda kapatır.
__dirname'i ESM'de aramak. ReferenceError alırsın; import.meta.dirname kullan.
Bir paketin hem ESM hem CommonJS kopyasını farkında olmadan yüklemek. Singleton beklediğin bir cache'in, logger'ın ya da configuration object'inin iki kopyası, ancak production'da garip davranışlarla kendini gösterir.
Küçük Bir Deneme
★☆☆ labs/03/price.js'i price.mjs olarak kopyala ve labs/03/package.json'daki "type" satırını sil. guess.cjs ve price.js nasıl davranıyor? Syntax detection hangi dosyada devreye giriyor?
★★☆ legacy-pricing.cjs'i named import'u destekleyecek şekilde değiştir ama createPricing fonksiyonunu koru. İpucu: export'u Node'un tanıyabileceği bir kalıpla yaz.
★★★ bookstore-pricing paketini yalnız ESM olacak şekilde yeniden düzenle, price-cache.cjs'i sil ve invoice.cjs'in require(esm) ile tek kopyayı kullandığını göster. exports haritası nasıl değişti?
Module'ler yerine oturdu; sıra dışarıdan gelen kodda. Bir sonraki yazıda npm'i ayrıntılı ele alıyoruz: SemVer aralıkları, package-lock.json, npm ci, node_modules'un nasıl kurulduğu, Bookstore için bir npm workspace ve supply chain güvenliği.