لغات البرمجة عملياً الأدلة التقنية

JavaScript لمن يعرف لغة أخرى: Node.js مع TypeScript والأدوات

إذا كنت تبرمج بلغة أخرى وانتقلت إلى فريق يعمل بـ Node.js وTypeScript، فهذا يومك الأول: الإصدار الصحيح، والصيغة مقارنة بما تعرفه، وأدوات العمل وهيكل المشروع بأوامر ومخرجات حقيقية.

JavaScript لمن يعرف لغة أخرى: Node.js مع TypeScript والأدوات

لنفرض أنك مطور تكتب Java أو C# أو Python منذ سنوات، وانتقلت هذا الأسبوع إلى شركة ناشئة تبيع القهوة والتمور والهيل عبر الإنترنت، فتجد أن كل شيء فيها مكتوب بلغة واحدة: الواجهة في المتصفح، وخدمة الطلبات على السيرفر، والسكربتات الصغيرة التي ترسل التقارير، كلها JavaScript وفوقها TypeScript، وتعمل على Node.js. وفي اليوم الأول يطلب منك قائد الفريق مهمة صغيرة: أداة سطر أوامر تحسب سعر السلة بالهللات مع ضريبة القيمة المضافة 15%، لها اختبار، وتمر على فحص الأنواع والتنسيق قبل الدمج. والمهمة نفسها سهلة على أي مبرمج، ولكن الأسئلة التي تقف أمامك ليست عن المنطق، وإنما عن البيئة: أي إصدار من Node.js أثبت؟ وهل أحتاج إلى بناء Build قبل التشغيل كما في Java؟ وما الفرق بين npm install وnpm ci؟ ولماذا يرفض Node.js سطر import يعمل في المحرر بلا مشكلة؟ وأين يكتب الاختبار، ومن يقرر أن المسافة البادئة مسافتان وليست Tab؟

وهذا المقال هو أول مقالات مرحلة JavaScript وTypeScript في مسار لغات البرمجة عملياً، وهو موجه لمن يعرف البرمجة بلغة أخرى ويريد أن يبدأ بشكل صحيح في هذه اللغة، أو يعود إليه كمرجع عندما ينشئ مشروعاً جديداً. فلن نشرح ما هو المتغير أو الحلقة، فهذا موضوع مقال أساسيات البرمجة، وإنما سوف نبني أداة الهللات خطوة خطوة داخل Container من Docker Image الرسمي لـ Node.js 26، وننقل كل أمر ومخرجه كما ظهر، ومعه الأخطاء التي واجهناها في الطريق.

وسوف نناقش في هذا المقال ما يلي:

  • أين تعمل JavaScript اليوم، وما الذي يوجد تحت الأمر node: المحرك V8 والمكتبة libuv والمعيار ECMAScript.
  • أي إصدار تثبت، وكيف تقرأ جدول إصدارات Node.js، ولماذا الإصدار الزوجي هو إصدار الإنتاج.
  • جولة سريعة في الصيغة مقارنة بما تعرفه من Java وPython وC#، مع ملف شغلناه فعلاً.
  • كيف يشغل Node.js ملفات TypeScript مباشرة بحذف الأنواع، وما الذي يبقى لمترجم tsc.
  • سلسلة الأدوات: npm وملف القفل، وtsc للفحص، وBiome للتنسيق والتدقيق، وnode --test للاختبارات.
  • تجربة عملية كاملة من npm init حتى الاختبارات، مع مشكلتين واجهناهما، وتجربة «ماذا يحدث إذا».
  • هيكل المشروع، والوحدات ESM وCommonJS، وزمن البدء مع حذف الأنواع وبدونه، ثم الأخطاء الشائعة وقائمة تحقق.

أين تعمل JavaScript اليوم؟ المتصفح Browser والسيرفر Server

بدأت JavaScript في 1995 لغة صغيرة داخل متصفح Netscape، كتبها Brendan Eich في أيام قليلة لتحريك عناصر الصفحة، وقد حكينا قصتها وقصص لغات أخرى في مقال قصص لغات البرمجة ومن صنعها. وبعدها بسنتين صار للغة معيار رسمي اسمه ECMAScript تصدره منظمة Ecma، وتطوره لجنة اسمها TC39 فيها ممثلون عن صناع المتصفحات والشركات الكبيرة، ومنذ 2015 يصدر المعيار كل سنة بإصدار جديد صغير بدلاً من إصدارات ضخمة متباعدة، وآخرها حتى كتابة هذا المقال هو الإصدار السابع عشر ECMAScript 2026. وكل ميزة جديدة تمر بمراحل من 0 إلى 4 في قائمة مقترحات TC39، ولا تدخل المعيار إلا بعد أن تطبقها محركات حقيقية، ولذلك فإن السؤال «هل هذه الميزة متاحة؟» يجاب عنه بإصدار المحرك الذي تستخدمه، وليس بإصدار اللغة فقط. وشرحنا الفرق بين المعيار والتطبيق والحوكمة في مقال مواصفات لغات البرمجة وتطبيقاتها وحوكمتها.

وفي 2009 أخذ Ryan Dahl محرك V8 من متصفح Chrome، ووضعه خارج المتصفح، وأضاف إليه مكتبة للإدخال والإخراج غير المتزامن صارت لاحقاً libuv، فولد Node.js، وصار بالإمكان كتابة سيرفر بنفس اللغة التي تكتب بها الواجهة. والفكرة التي جعلت Node.js ينتشر في السيرفرات ليست اللغة نفسها، وإنما نموذج التنفيذ: خيط واحد Thread ينفذ كودك، وحلقة أحداث Event Loop تستقبل نتائج الشبكة والملفات عندما تجهز، فلا يحجز كل اتصال خيطاً كاملاً ينتظر. وهذا النموذج شرحناه بالتفصيل وقسناه في مقال حلقة الأحداث Event Loop، وسوف نعود إليه في مقال التزامن من هذه المرحلة.

والصورة التالية تبين ما يوجد تحت الأمر node عندما تشغله، بالإصدارات التي طبعها في التجربة:

ثلاثة أعمدة: كودك بملفات js وts، ثم Node.js v26.13.0 وفيه المحرك V8 14.6 ومكتبة libuv للحلقة والشبكة والملفات والمكتبات القياسية، ثم نظام التشغيل باستدعاءات مثل epoll، وتحتها npm 11.20.0 والمتصفح الذي فيه محرك مثل V8 مع DOM، والمعيار ECMAScript
نفس اللغة في المتصفح وفي Node.js، والفرق في المكتبات حول المحرك

والنقطة التي يجب أن تأخذها من الصورة أن «JavaScript» في المتصفح و«JavaScript» في Node.js هي نفس اللغة بنفس المعيار، ولكن ما حولها مختلف تماماً: في المتصفح لديك DOM والنوافذ وواجهات الصفحة، ولا يوجد نظام ملفات، وفي Node.js لديك node:fs وnode:http وnode:crypto ولا توجد صفحة. ولذلك فإن مكتبة تستخدم document لن تعمل في السيرفر، ومكتبة تستخدم fs لن تعمل في المتصفح، وهذا يفسر كثيراً من رسائل الخطأ التي تظهر للقادمين الجدد. وتوجد بيئات تشغيل أخرى خارج المتصفح مثل Deno وBun، وكلها تشغل نفس اللغة بمحركات وأدوات مختلفة، ولكن Node.js هو الأكثر استخداماً في الشركات، وهو ما سوف نستخدمه في كل مقالات هذه المرحلة.

والمحرك V8 لا يفسر الكود سطراً سطراً بالطريقة القديمة، وإنما يحوله إلى Bytecode ينفذه مفسر سريع، ثم يرصد الدوال التي تتكرر كثيراً فيترجمها إلى كود الآلة أثناء التشغيل Just-In-Time، وهذا ما يجعل JavaScript أسرع بكثير مما يتوقع من لغة ديناميكية. وإذا كنت قادماً من Java أو C# فهذه نفس فكرة JVM وCLR تقريباً، والفرق أنه لا توجد خطوة ترجمة منفصلة يراها المطور، فأنت تعطي Node.js الملف المصدري مباشرة. وقد شرحنا المترجم والمفسر والـ JIT بالتفصيل في مقال المترجم والمفسر وJIT وWebAssembly.

أي إصدار تثبت؟ جدول إصدارات Node.js Release Schedule

أول قرار يواجهك هو الإصدار، وهو قرار يتكرر كل سنة، لذلك من المفيد أن تفهم القاعدة بدلاً من أن تحفظ رقماً. فمشروع Node.js ينشر جدولاً رسمياً في مستودع فريق الإصدارات، وفي صفحة الإصدارات السابقة على الموقع، والقاعدة فيه أن كل إصدار رئيسي يمر بثلاث مراحل:

  • الإصدار الحالي Current: يبدأ في الربيع، ويستقبل الميزات الجديدة بسرعة، وهو للتجربة ولمطوري المكتبات.
  • الدعم طويل المدى النشط Active LTS: في أكتوبر يدخل الإصدار الزوجي هذه المرحلة، فيأخذ اسماً رمزياً، ويستقبل الإصلاحات والتحسينات الآمنة فقط، وهذا هو إصدار الإنتاج.
  • دعم الصيانة Maintenance LTS: بعد سنة تقريباً ينتقل إلى إصلاحات الأمان والأخطاء الحرجة فقط، ثم ينتهي دعمه بعد 30 شهراً من بدايته تقريباً.

وبحسب الجدول الرسمي وقت كتابة هذا المقال، فإن Node.js 26 بالاسم الرمزي Lithium دخل مرحلة Active LTS في 28 أكتوبر 2026 وينتهي دعمه في أبريل 2029، وNode.js 24 بالاسم Krypton انتقل إلى الصيانة في 20 أكتوبر 2026، وNode.js 22 بالاسم Jod في الصيانة حتى أبريل 2027. وفي نفس الجدول ظهر Node.js 27 كإصدار Alpha، لأن المشروع بدأ يجرب نموذجاً جديداً للإصدارات. والقاعدة العملية لمشروع جديد بسيطة: ابدأ بآخر إصدار في Active LTS، أي Node.js 26 الآن، ولا تضع الإصدارات الفردية مثل 25 في الإنتاج، والسبب أن الإصدار الفردي لا يدخل LTS أبداً، وينتهي دعمه بعد أشهر قليلة، فتجد نفسك مضطراً للترقية في وقت لم تختره.

وقد يتساءل البعض: لماذا لا أثبت Node.js من مستودع التوزيعة نفسها بالأمر apt install nodejs؟ والإجابة أن هذا هو الطريق الأسهل والأسوأ في نفس الوقت، فالتوزيعات المستقرة تجمد الإصدار عند بداية دورتها، فقد تجد على السيرفر إصداراً انتهى دعمه أو قارب، ومعه npm قديم. والطريق الثاني الذي يقترحه كثير من الشروحات هو تحميل سكربت من الإنترنت وتمريره مباشرة إلى bash، وهو يعطيك إصداراً حديثاً، ولكنه يشغل كوداً لم تقرأه بصلاحيات root. والطريقة الأنسب تختلف بحسب المكان:

  • جهاز المطور: مدير إصدارات مثل nvm أو fnm، والسبب أنك ستعمل على أكثر من مشروع، وكل مشروع قد يحتاج إصداراً مختلفاً، ومدير الإصدارات يقرأ الإصدار من ملف .nvmrc في المشروع ويبدل بينها بأمر واحد.
  • جهاز Windows أو macOS لمن يريد إصداراً واحداً: المثبت الرسمي من صفحة التحميل.
  • السيرفر والـ CI والتجارب: Docker Image الرسمي بإصدار مثبت بالكامل مثل node:26.13.0-slim، وليس node:latest، والسبب أن latest يتغير تحت قدميك ويأخذ الإصدار الفردي أيضاً.

وفي هذا المقال استخدمنا الطريقة الثالثة، فكل الأوامر عملت داخل Container من node:26.13.0-slim المبني على Debian 13، وأول ما طبعناه هو الإصدارات:

node --version
npm --version
node -p "process.versions.v8"
node -p "process.features.typescript"

والمخرج سوف يكون كما يلي:

v26.13.0
11.20.0
14.6.202.34-node.40
strip

في المخرج أعلاه لاحظ التالي:

  • السطر الأول هو إصدار Node.js، والثاني إصدار npm الذي يأتي معه، فلا تثبت npm منفصلاً إلا إذا احتجت إصداراً أحدث لسبب واضح.
  • السطر الثالث إصدار المحرك V8، وهو الذي يحدد ميزات اللغة المتاحة، فميزة دخلت المعيار حديثاً تعمل إذا كان المحرك يطبقها.
  • السطر الرابع يقول إن هذا الإصدار يدعم TypeScript بطريقة strip، أي حذف الأنواع، وسوف نعود إلى معناه بعد قليل.

جولة سريعة في الصيغة Syntax لمن يعرف لغة أخرى

أغلب صيغة JavaScript مألوفة لمن يكتب Java أو C# أو C، فالأقواس المعقوفة والفاصلة المنقوطة وif وfor وwhile وswitch وtry كلها كما تعرفها. والمشكلة ليست في ما يشبه لغتك، وإنما في ما يشبهها شكلاً ويختلف عنها سلوكاً، لذلك كتبنا ملفاً واحداً اسمه tour.ts يمر على الأشياء التي يسأل عنها القادمون من لغات أخرى، وشغلناه مباشرة بـ Node.js:

// A quick tour for developers coming from another language.  node src/tour.ts
// 1. Variables: const by default, let when you reassign, never var
const vatPercent = 15;
let count = 0;
count += 1;

// 2. Equality: === compares without converting types
console.log('1. ==  vs ===  :', '15' == 15 as unknown, '15' === (15 as unknown));

// 3. Functions: declarations, arrows, default and rest parameters
function addVat(halalas: number, percent = vatPercent): number {
  return halalas + Math.floor((halalas * percent + 50) / 100);
}
const sum = (...xs: number[]) => xs.reduce((a, b) => a + b, 0);
console.log('2. functions   :', addVat(2970), sum(990, 990, 990));

// 4. Objects and arrays, destructuring and spread
const product = { id: 3, nameAr: 'هيل حب 100 غرام', priceHalalas: 990, stock: 10 };
const { nameAr, ...rest } = product;
const restocked = { ...product, stock: product.stock + 5 };
console.log('3. objects     :', nameAr, rest, restocked.stock, product.stock);
const prices = [4500, 3800, 990];
console.log('4. arrays      :', prices.map(p => p / 100), prices.filter(p => p > 1000).length, prices.includes(990));

// 5. Classes with private fields (#) and a type for the shape
type Line = { productId: number; qty: number };
class Cart {
  #lines: Line[] = [];
  add(productId: number, qty = 1): this { this.#lines.push({ productId, qty }); return this; }
  get size(): number { return this.#lines.reduce((n, l) => n + l.qty, 0); }
}
console.log('5. class       :', new Cart().add(3, 2).add(1).size);

// 6. null and undefined, optional chaining and nullish coalescing
const order: { customer?: { phone?: string } } = {};
console.log('6. ?. and ??   :', order.customer?.phone ?? 'no phone', 0 || 'fallback', 0 ?? 'fallback');

// 7. Template literals and string methods
console.log(`7. template    : ${count} item, ${(addVat(990) / 100).toFixed(2)} SAR`.padEnd(10));

// 8. Async: every I/O function returns a Promise, you wait for it with await
const wait = (ms: number) => new Promise<string>(r => setTimeout(() => r(`waited ${ms} ms`), ms));
console.log('8. await       :', await wait(20));

// 9. Errors are thrown and caught like Java or C#
try { JSON.parse('{bad'); } catch (e) { console.log('9. try/catch   :', (e as Error).name); }

والمخرج سوف يكون كما يلي:

1. ==  vs ===  : true false
2. functions   : 3416 2970
3. objects     : هيل حب 100 غرام { id: 3, priceHalalas: 990, stock: 10 } 15 10
4. arrays      : [ 45, 38, 9.9 ] 2 true
5. class       : 3
6. ?. and ??   : no phone fallback 0
7. template    : 1 item, 11.39 SAR
8. await       : waited 20 ms
9. try/catch   : SyntaxError

وهذا المخرج يحتاج قراءة سطراً سطراً، لأن كل سطر فيه درس:

  • السطر 1: '15' == 15 أعطى true، لأن == يحول النص إلى رقم قبل المقارنة، و=== أعطى false لأنه يقارن النوع والقيمة معاً. والقاعدة التي تتبعها كل الفرق تقريباً: استخدم === دائماً، وسوف ترى بعد قليل أن أداة التدقيق تمنع == تلقائياً. ولاحظ أننا احتجنا as unknown حتى يقبل TypeScript المقارنة أصلاً، فهو يعرف أن مقارنة نص برقم خطأ في أغلب الحالات.
  • السطر 2: الدالة addVat أخذت المعامل الثاني من القيمة الافتراضية، فأعطت 3416 هللة لسلة قيمتها 2970، أي 29.70 ريالاً مع ضريبة 4.46. ولاحظ أننا نحسب بالهللات كأعداد صحيحة، والسبب سوف تراه في مقال الأنواع من هذه المرحلة عندما نضرب 9.90 في 3.
  • السطر 3: الـ Destructuring أخرج nameAr وجمع الباقي في rest، والـ Spread أنشأ كائناً جديداً بمخزون 15، وبقي الأصل 10. وهذه النسخة سطحية Shallow Copy، فإذا كان في الكائن مصفوفة فالنسختان تشيران إلى نفس المصفوفة، وهذا من أكثر الأخطاء التي سوف نقيسها في مقال الأخطاء الشائعة.
  • السطر 4: map وfilter وincludes تعمل كما في Streams في Java أو LINQ في C#، ولكنها ليست كسولة Lazy، فكل خطوة تنشئ مصفوفة جديدة كاملة.
  • السطر 5: الحقل #lines خاص فعلاً وقت التشغيل، فلا يمكن الوصول إليه من خارج الكلاس حتى من JavaScript عادية، وهذا يختلف عن الكلمة private في TypeScript التي تحذف مع الأنواع ولا تمنع شيئاً وقت التشغيل.
  • السطر 6: هنا الفرق الذي يربك القادمين من Python: 0 || 'fallback' أعطى fallback لأن || يعامل الصفر كقيمة «خاطئة» Falsy، أما 0 ?? 'fallback' فأعطى 0 لأن ?? لا يستبدل إلا null وundefined. وإذا كان المخزون أو الخصم قد يكون صفراً فاستخدم ?? دائماً.
  • السطر 8: await في أعلى الملف تعمل مباشرة، لأن الملف وحدة ES Module كما سوف نرى، وهذه الميزة اسمها Top-level await.

والصورة التالية تجمع هذه المقارنات في جدول واحد يمكنك الرجوع إليه:

جدول من ثمانية صفوف يقارن JavaScript وTypeScript مع Python وJava وC#: المتغير بـ const وlet، والمساواة بثلاث علامات، والدالة السهمية، وnull وundefined، والكائن البسيط، والكلاس بحقل خاص، والوحدات بامتداد إجباري، وانتظار I/O بـ await، مع عمود لما يجب الانتباه له
ما يشبه لغتك شكلاً ويختلف عنها سلوكاً

وهناك ثلاثة أشياء في JavaScript لا يوجد ما يقابلها تماماً في أغلب اللغات، ويستحق كل واحد منها انتباهك من اليوم الأول. الأول هو this، فقيمته لا تحدد بمكان كتابة الدالة، وإنما بطريقة استدعائها، فإذا مررت دالة من كلاس كمعامل إلى دالة أخرى فقد تفقد this، والدوال السهمية Arrow Functions تحل ذلك لأنها تأخذ this من المكان الذي كتبت فيه. والثاني أن الأرقام كلها من نوع واحد هو number بدقة 64 بت عشرية، فلا يوجد int منفصل، والثالث أن كل عملية إدخال وإخراج ترجع وعداً Promise ولا توقف الخيط، وهذا عكس ما تعودت عليه في Java أو Python التقليدية حيث ينتظر الخيط حتى تصل النتيجة. والثلاثة لها مقالات كاملة في هذه المرحلة، فلا تحاول حفظ قواعدها الآن، ولكن تذكر أنها موجودة عندما ترى نتيجة غريبة.

TypeScript فوق JavaScript: ماذا يفعل Node.js بالأنواع؟ Type Stripping

لاحظت أننا شغلنا ملف .ts بالأمر node مباشرة، دون أي خطوة بناء، وهذا جديد نسبياً. فقبل 2025 كان تشغيل TypeScript يحتاج إما أن تترجم الكود بـ tsc إلى مجلد JavaScript ثم تشغله، أو أن تستخدم أداة وسيطة مثل ts-node أو tsx تترجم في الذاكرة. أما الآن فإن Node.js نفسه يقرأ ملف TypeScript، ويحذف منه كل ما هو نوع، ويشغل ما تبقى، وهذه الميزة اسمها حذف الأنواع Type Stripping. وبحسب صفحة TypeScript في توثيق Node.js 26 فقد أضيفت الميزة في الإصدار 22.6 خلف خيار، ثم صارت مفعلة افتراضياً في 23.6، ثم مستقرة Stable في 25.2 و24.12، وفي الإصدار 26 حذف الخيار القديم --experimental-transform-types نهائياً.

وكلمة «حذف» هنا دقيقة، فـ Node.js لا يفحص الأنواع إطلاقاً، وإنما يستبدل كل نوع بمسافات ويترك الباقي كما هو، ولذلك يعمل الكود الذي فيه خطأ أنواع وكأن شيئاً لم يكن، وسوف ترى ذلك بعينك في التجربة. وهذا يعني أن العمل صار مقسماً على أداتين: Node.js يشغل، والمترجم tsc يفحص. والحذف بهذه الطريقة يعمل فقط مع الصيغ التي يمكن حذفها دون أن تولد كوداً، فـ enum وخصائص المنشئ Parameter Properties وnamespace التي فيها كود لا تعمل، لأن كل واحدة منها تحتاج أن يولد المترجم كود JavaScript جديداً. وقد شرحنا هذا كله بالتفصيل مع تجربة الأخطاء التسعة في مقال TypeScript للمطورين: أي الأخطاء تكشفها الأنواع؟، فلن نكرره هنا، ويكفي أن تعرف أن الخيار erasableSyntaxOnly في tsconfig.json يجعل tsc يرفض هذه الصيغ من البداية، فلا تكتشفها وقت التشغيل.

وقد يتساءل البعض: إذا كان Node.js يشغل TypeScript، فما الحاجة إلى tsc أصلاً؟ والإجابة أن الفائدة كلها من TypeScript هي الفحص، والحذف وحده يعطيك ملفاً يعمل دون أي ضمان، فهو أشبه بأن تكتب الأنواع كتعليقات. لذلك فإن الشكل الذي تعمل به أغلب الفرق اليوم هو: المحرر يفحص أثناء الكتابة، وtsc --noEmit يفحص في CI ويمنع الدمج عند الخطأ، وNode.js يشغل الملفات مباشرة في التطوير، وأحياناً في الإنتاج أيضاً كما سوف نناقش عند الكلام عن زمن البدء.

أدوات العمل Toolchain: التشغيل والحزم والفحص والتنسيق

في Java تعرف أن JDK يعطيك المترجم javac وأن Maven أو Gradle يبني المشروع ويثبت المكتبات، وفي Python تعرف pip وبيئة venv. وفي عالم Node.js توجد أدوات كثيرة لكل خطوة، وهذا من أكثر ما يربك القادمين، لأن كل مقال تقرؤه يستخدم مجموعة مختلفة. لذلك سوف نحدد لكل خطوة أداة واحدة معقولة لمشروع جديد، ونذكر البدائل في جملة:

  • التشغيل Runtime: node نفسه، وهو أيضاً الذي يحذف الأنواع كما رأينا.
  • مدير الحزم Package Manager: npm الذي يأتي مع Node.js، والبدائل المعروفة pnpm وYarn، وقد قارنا مديري الحزم في اللغات المختلفة في مقال مديرو الحزم npm وpip وMaven وCargo.
  • فحص الأنواع Type Checker: tsc من الحزمة typescript، والإصدار 7 منها مترجم أصلي مكتوب بلغة Go وأسرع بكثير من الإصدارات السابقة.
  • التنسيق Formatter والتدقيق Linter: Biome، وهو أداة واحدة تقوم بعمل Prettier وESLint معاً، وأسرع منهما لأنها مكتوبة بـ Rust. والبديل التقليدي هو ESLint مع Prettier، وهو ما سوف تجده في أغلب المشاريع القديمة، وBiome يوفر دليل انتقال منهما.
  • الاختبارات Test Runner: المشغل المدمج node:test، والبدائل Vitest وJest، وسوف نستخدم المدمج في هذه المرحلة لأنه لا يحتاج أي حزمة.

والصورة التالية تبين هذه الأدوات بالإصدارات التي استخدمناها، وما يفعله كل منها:

أربعة صناديق: التشغيل بـ node v26.13.0 الذي يحذف الأنواع ولا يفحصها، والحزم بـ npm 11.20.0 مع package.json وpackage-lock.json، والفحص بـ tsc من TypeScript 7.0.2، والتنسيق والتدقيق بـ Biome 2.6.4، وتحتها الاختبارات بـ node --test، وما لا يحتاجه مشروع سيرفر جديد مثل Babel وts-node وwebpack، وسطر يقول إن التثبيت كان 6 حزم بحجم 119 MB
أداة واحدة لكل خطوة، وكلها تعمل من سكربتات package.json

ولاحظ أن القائمة لا تحتوي على Babel ولا webpack ولا ts-node، وهذه أدوات ستجدها في أغلب الشروحات القديمة، وكانت ضرورية عندما كان Node.js لا يفهم import ولا TypeScript. أما في مشروع سيرفر جديد على Node.js 26 فلا تحتاج أياً منها، وكل أداة إضافية هي إعداد آخر يجب أن تفهمه وتحدثه. وأدوات التجميع Bundlers مثل Vite وesbuild مكانها الواجهات في المتصفح، وقد شرحنا أدوات البناء بشكل عام في مقال أدوات البناء Build Tools.

التجربة العملية Lab: أداة الهللات من الصفر

الآن سوف نبني المهمة التي طلبها قائد الفريق: مشروع صغير اسمه halala-cli يحسب سعر السلة بالهللات مع الضريبة، فيه دالة الحساب، وملف التشغيل، واختبار، ويمر على tsc وBiome. وكل الأوامر التالية عملت داخل Container مؤقت، والملفات كاملة موجودة في مجلد lab مع المقال، والسكربت الذي جمعها اسمه run.sh، ويمكنك تشغيله على جهازك بالأمر:

docker run --rm -v "$PWD:/lab:ro" node:26.13.0-slim sh /lab/run.sh

الخطوة الأولى: package.json وملف المصدر

كل مشروع Node.js يبدأ بملف package.json، وهو يقابل pom.xml في Maven أو pyproject.toml في Python: اسم المشروع، وإصداره، وسكربتاته، والحزم التي يعتمد عليها. وننشئه بالأمر npm init، ثم نضيف الحقل type بقيمة module:

npm init -y
npm pkg set type=module name=halala-cli
cat package.json

والمخرج سوف يكون كما يلي:

{
  "name": "halala-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module"
}

والحقل "type": "module" هو أهم سطر في الملف، لأنه يقول لـ Node.js إن ملفات .js و.ts في هذا المشروع وحدات ES Modules تستخدم import وexport، وسوف ترى في تجربة «ماذا يحدث إذا» ما يحدث عندما تحذفه. والحقل main وسكربت test الافتراضي لا يفيداننا، وسوف نستبدلهما بعد قليل.

بعد ذلك نكتب دالة الحساب في src/price.ts، والقاعدة الأولى فيها أن المبالغ أعداد صحيحة بالهللات وليست كسوراً بالريال:

// Prices are integers in halalas (1 SAR = 100 halalas), never floats.
export type Item = { name: string; priceHalalas: number; qty: number };

export const VAT_PERCENT = 15;

export function subtotal(items: Item[]): number {
  return items.reduce((sum, it) => sum + it.priceHalalas * it.qty, 0);
}

// VAT rounded half up, in integer arithmetic only
export function vat(halalas: number): number {
  return Math.floor((halalas * VAT_PERCENT + 50) / 100);
}

export function formatSar(halalas: number): string {
  return `${(halalas / 100).toFixed(2)} ر.س`;
}

والدالة vat تقرب النصف إلى الأعلى Round Half Up بعملية صحيحة: نضرب في 15، ونضيف 50، ثم نقسم على 100 ونأخذ الجزء الصحيح. فسلة بـ 2970 هللة ضريبتها 44550 على 100، أي 445.5 هللة، وبإضافة 50 قبل القسمة تصبح 446. وهذا الحساب نفسه هو الذي سوف نستخدمه في خدمة الطلبات في مقال الخدمة الحقيقية، وفي كل لغات المسار، حتى تكون المقارنة عادلة. ثم ملف التشغيل src/cli.ts:

import { subtotal, vat, formatSar, type Item } from './price.ts';

const cart: Item[] = [
  { name: 'هيل حب 100 غرام', priceHalalas: 990, qty: 3 },
  { name: 'تمر سكري 1 كيلو', priceHalalas: 3800, qty: 1 },
];

const sub = subtotal(cart);
const tax = vat(sub);
for (const { name, priceHalalas, qty } of cart) {
  console.log(`${qty} x ${name} @ ${formatSar(priceHalalas)}`);
}
console.log(`subtotal ${formatSar(sub)} | VAT ${formatSar(tax)} | total ${formatSar(sub + tax)}`);

ولاحظ في سطر import شيئين: الامتداد .ts مكتوب صراحة، والكلمة type قبل Item تقول إن هذا الاسم نوع فقط يحذف بالكامل. ونشغل الملف مباشرة قبل تثبيت أي حزمة:

node src/cli.ts

والمخرج سوف يكون كما يلي:

3 x هيل حب 100 غرام @ 9.90 ر.س
1 x تمر سكري 1 كيلو @ 38.00 ر.س
subtotal 67.70 ر.س | VAT 10.16 ر.س | total 77.86 ر.س

وهذه أول نقطة تختلف عن Java وC#: لا توجد خطوة ترجمة، ولا توجد حزمة مثبتة، وملف TypeScript عمل بـ Node.js وحده. والمجموع صحيح: 2970 للهيل و3800 للتمر يعطيان 6770 هللة، وضريبتها 1015.5 تقرب إلى 1016.

الخطوة الثانية: تثبيت الأدوات وملف القفل Lockfile

الآن نثبت أدوات التطوير: TypeScript للفحص، وتعريفات أنواع Node.js، وBiome. والخيار -D يضعها في devDependencies، أي أنها تلزم للتطوير وليس لتشغيل الأداة:

npm install -D --no-fund typescript@7 @types/node@26 @biomejs/biome
npm ls --depth=0
du -sh node_modules

والمخرج سوف يكون كما يلي:

added 6 packages, and audited 7 packages in 1m

found 0 vulnerabilities
install took 85305 ms
[email protected] /work
+-- @biomejs/[email protected]
+-- @types/[email protected]
`-- [email protected]

packages in node_modules: 6
119M	node_modules

في المخرج أعلاه لاحظ التالي:

  • ثلاث حزم طلبناها، وثبت npm ست حزم، لأن كل حزمة تجلب ما تعتمد عليه: Biome يجلب ملفه التنفيذي الخاص بنظام Linux x64 كحزمة منفصلة، و@types/node يجلب undici-types، وTypeScript 7 يجلب مترجمه الأصلي.
  • حجم node_modules 119 ميجابايت لثلاث أدوات فقط، وأغلبه ملفات تنفيذية أصلية، وهذا سبب أن المجلد لا يدخل Git أبداً، وأن ترتيب طبقات Dockerfile مهم كما سوف نرى في مقال الخدمة.
  • التثبيت أخذ 85 ثانية في المرة الأولى، وأغلب الوقت تحميل من الشبكة داخل Docker Desktop.
  • السطر found 0 vulnerabilities نتيجة npm audit الذي يعمل تلقائياً مع كل تثبيت ويقارن الحزم بقاعدة الثغرات المعروفة.

ومع التثبيت ظهر ملف جديد اسمه package-lock.json بحجم 19 كيلوبايت تقريباً، وهو أهم ملف لا يقرؤه أحد. ففي package.json كتب npm المدى ^7.0.2، أي «7.0.2 أو أي إصدار أحدث في السلسلة 7»، أما ملف القفل فيسجل الإصدار الدقيق الذي ثبت لكل حزمة وهاش محتواها، كما يشرح توثيق package-lock.json. ولذلك يجب أن يدخل Git، وفي CI وفي Docker تستخدم الأمر npm ci بدلاً من npm install، والسبب كما في توثيق npm ci أنه يحذف node_modules ويثبت من ملف القفل حرفياً، ويفشل إذا كان الملفان غير متوافقين بدلاً من أن يعدل ملف القفل بصمت. وعندما جربناه بعد حذف node_modules أخذ 13.5 ثانية بدلاً من 85، لأن الحزم صارت في ذاكرة npm المؤقتة Cache.

⚠️
لا تحذف package-lock.json لتحل مشكلة تثبيت، فهذا يرفع كل الحزم غير المباشرة إلى آخر إصدار في مداها مرة واحدة، وقد تدخل معها مشكلة لا علاقة لها بما كنت تحاول إصلاحه. حدث الحزم بشكل مقصود بالأمر npm update أو npm install اسم-الحزمة@الإصدار، وراجع الفرق في ملف القفل قبل الدمج.

وفي مارس 2016 حذف مطور حزمة صغيرة اسمها left-pad من سجل npm بعد خلاف مع الشركة، وكانت الحزمة من 11 سطراً تعتمد عليها مكتبات كبيرة بشكل غير مباشر، فتوقف بناء آلاف المشاريع حول العالم ساعات، وغيرت npm بعدها سياسة حذف الحزم المنشورة كما شرحت في تدوينتها عن الحادثة. والدرس الذي بقي من القصة أن شجرة الاعتماديات في JavaScript أعمق مما تتوقع، فكل حزمة تضيفها تجلب معها حزماً لم تخترها، ولذلك فإن ملف القفل ومراجعة ما تضيفه ليسا رفاهية.

الخطوة الثالثة: الفحص بـ tsc وملف tsconfig.json

الآن نكتب tsconfig.json الذي يقول لـ tsc كيف يفحص، والفكرة فيه أن tsc لا يخرج أي ملف، وإنما يفحص فقط، والتشغيل يبقى لـ Node.js:

{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}

في الإعداد أعلاه لاحظ التالي:

  • module: nodenext: يجعل tsc يحل الوحدات بنفس قواعد Node.js، فيقرأ "type" من package.json ويطلب الامتداد في كل import نسبي، فيكتشف خطأ الامتداد قبل التشغيل.
  • strict: true: يفعل مجموعة الفحوص الصارمة، وأهمها أن null وundefined ليستا من كل الأنواع، وقد صار مفعلاً افتراضياً منذ TypeScript 6.
  • noEmit وallowImportingTsExtensions: الثاني يسمح بكتابة .ts في import، وهو مسموح فقط عندما لا يخرج المترجم ملفات، لأن الملف الخارج سيكون .js.
  • erasableSyntaxOnly وverbatimModuleSyntax: الأول يرفض enum وما شابهها مما لا يعمل مع الحذف، والثاني يجبرك على كتابة import type أو type للأنواع، حتى يعرف الحذف ما يحذفه من سطر import.
  • types: ["node"]: يحمل تعريفات Node.js فقط، فلا تظهر أنواع مكتبات أخرى في المشروع دون أن تطلبها.

ونشغل الفحص على الكود الصحيح أولاً، ثم نزرع خطأ واحداً من النوع الذي يحدث فعلاً: سعر وصل من نموذج أو من ملف CSV كنص "38.00" بدلاً من رقم:

npx tsc -p . && echo "tsc: no errors"
sed 's/priceHalalas: 3800, qty: 1/priceHalalas: "38.00", qty: 1/' src/cli.ts > /tmp/cli.ts && cp /tmp/cli.ts src/cli.ts
node src/cli.ts
npx tsc -p .; echo "tsc exit $?"

والمخرج سوف يكون كما يلي:

tsc: no errors

3 x هيل حب 100 غرام @ 9.90 ر.س
1 x تمر سكري 1 كيلو @ 0.38 ر.س
subtotal 30.08 ر.س | VAT 4.51 ر.س | total 34.59 ر.س
src/cli.ts(5,30): error TS2322: Type 'string' is not assignable to type 'number'.
tsc exit 1

وهذا المخرج هو أهم ما في المقال كله، فاقرأه بتمهل. فقد عمل Node.js بالملف الخاطئ دون أي اعتراض، لأنه يحذف الأنواع ولا يفحصها، وأعطى فاتورة خاطئة بالكامل: التمر صار بـ 0.38 ريال لأن formatSar قسمت النص "38.00" على 100 فحولته JavaScript إلى رقم، والمجموع صار 30.08 لأن "38.00" * 1 أعطى الرقم 38 فجمع مع 2970. ولم يرم أي سطر استثناء، ولو كانت هذه خدمة حقيقية لحفظ الطلب بهذا المبلغ. أما tsc فأمسك الخطأ في السطر 5 العمود 30 بالضبط، وخرج برمز 1، وهذا الرمز هو ما يجعل خطوة CI تفشل وتمنع الدمج. ولهذا السبب فإن tsc ليس اختيارياً في مشروع TypeScript، حتى لو كان Node.js يشغل الملفات بدونه.

الخطوة الرابعة: الاختبارات مع node:test

المشغل المدمج node:test يكفي لأغلب المشاريع، وصيغته قريبة مما تعرفه من JUnit أو pytest: دالة test باسم وجسم، ومكتبة assert للمقارنات. ونكتب في test/price.test.ts اختبار الضريبة بالرقم الذي نستخدمه في كل المسار، 9.90 ريال في 3 تساوي 29.70، وضريبتها 4.455 تقرب إلى 4.46، والمجموع 34.16:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { subtotal, vat } from '../src/price.ts';

test('VAT rounds half up: 29.70 SAR -> 4.46', () => {
  const sub = subtotal([{ name: 'x', priceHalalas: 990, qty: 3 }]);
  assert.equal(sub, 2970);
  assert.equal(vat(sub), 446);
  assert.equal(sub + vat(sub), 3416);
});

test('empty cart costs nothing', () => {
  assert.equal(subtotal([]), 0);
  assert.equal(vat(0), 0);
});

ونشغل الاختبارات بالأمر node --test دون أي معامل:

✔ VAT rounds half up: 29.70 SAR -> 4.46 (1.724708ms)
✔ empty cart costs nothing (0.333075ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1029.912013

ولاحظ أننا لم نخبر Node.js أين الاختبارات، فهو يبحث افتراضياً عن ملفات بأسماء مثل *.test.ts و*.test.js في المشروع، ويشغل كل ملف في عملية Process منفصلة، ولذلك أخذت الجولة كلها ثانية تقريباً بينما الاختباران نفسهما أقل من 2 ميلي ثانية، فأغلب الوقت تشغيل Node.js نفسه وحذف الأنواع. واستخدمنا node:assert/strict وليس node:assert، والسبب أن النسخة القديمة تقارن بـ == في بعض الدوال، فتعتبر '446' مساوية لـ 446. وإذا احتجت لاحقاً ميزات مثل المحاكاة Mocking المتقدمة أو واجهة رسومية للنتائج، فـ Vitest هو الخيار الأقرب، وقد شرحنا أنواع الاختبارات ومتى تستخدم كل نوع في مقال أنواع الاختبارات.

الخطوة الخامسة: التنسيق والتدقيق مع Biome

الآن نجرب Biome على ملف صغير فيه ثلاث مشكلات من النوع الذي يظهر في مراجعة الكود كل يوم: متغير لا يستخدم، وlet لمتغير لا يتغير، و== بدلاً من ===:

export function isFree(price: number) {
  let unused = 1
  if (price == 0) { return true }
  return false
}
npx biome check src/bad.ts

والمخرج سوف يكون كما يلي، مختصراً إلى أسطر العناوين:

biome exit 1
src/bad.ts:2:3 lint/style/useConst  FIXABLE
src/bad.ts:2:7 lint/correctness/noUnusedVariables  FIXABLE
src/bad.ts:3:13 lint/suspicious/noDoubleEquals  FIXABLE
src/bad.ts format
Checked 1 file in 10ms. No fixes applied.
Found 2 errors.
Found 2 warnings.

وكل سطر هنا قاعدة لها اسم ومجموعة: useConst من قواعد الأسلوب، وnoUnusedVariables من قواعد الصحة، وnoDoubleEquals من القواعد «المشبوهة» التي تكشف أخطاء محتملة، ثم format يعني أن شكل الملف لا يطابق المنسق. وكل قاعدة في المخرج الكامل تأتي مع شرح وسبب واقتراح إصلاح، مثل «المتغير الذي لا يعاد إسناده أوضح كـ const». وعندما نطلب الإصلاح بالأمر npx biome check --write --unsafe src/bad.ts يصبح الملف كما يلي:

export function isFree(price: number) {
	const _unused = 1;
	if (price === 0) {
		return true;
	}
	return false;
}

ولاحظ الخيار --unsafe، فتحويل == إلى === قد يغير سلوك الكود إذا كان أحدهم يعتمد على التحويل، لذلك لا يطبقه Biome إلا عندما تطلبه صراحة، أما --write وحده فيطبق الإصلاحات الآمنة فقط. ولاحظ أيضاً أن الملف صار بـ Tab وليس بمسافتين، وأن المتغير غير المستخدم لم يحذف وإنما أضيف قبله _، وهذا ما قادنا إلى المشكلة الأولى في الفقرة التالية.

مشكلتان واجهناهما في التجربة Troubleshooting

المشكلة الأولى ظهرت عندما شغلنا Biome على المشروع كله بالأمر npm run lint، فرفض كل الملفات تقريباً، حتى package.json الذي أنشأه npm نفسه:

package.json format
    10    │ - ····"format":·"biome·format·--write·."
       10 │ + → → "format":·"biome·format·--write·."
src/cli.ts:1:1 assist/source/organizeImports  FIXABLE
     1    │ - import·{·subtotal,·vat,·formatSar,·type·Item·}·from·'./price.ts';
        1 │ + import·{·formatSar,·type·Item,·subtotal,·vat·}·from·'./price.ts';
src/cli.ts format
     1    │ - import·{·subtotal,·vat,·formatSar,·type·Item·}·from·'./price.ts';
        1 │ + import·{·subtotal,·vat,·formatSar,·type·Item·}·from·"./price.ts";

والسبب أن Biome بدون ملف إعداد يستخدم Tab للمسافة البادئة وعلامات التنصيص المزدوجة، ويرتب أسماء import أبجدياً، بينما كتبنا الكود بمسافتين وعلامات مفردة كما في أغلب مشاريع JavaScript. والحل ليس أن تتجاهل الأداة، وإنما أن تقرر الأسلوب مرة واحدة وتكتبه في ملف، فأنشأنا الإعداد بالأمر npx biome init، وغيرنا فيه قيمتين:

{
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single"
    }
  }
}

ثم شغلنا npx biome check --write . فأصلح ثلاثة ملفات، وبقي خطأ واحد مقصود في tour.ts لأنه يستخدم == ليعرضه، وهذا ما يجب أن يحدث، فالقاعدة تمنعه في كل الكود ما لم تضع فوقه تعليق استثناء صريحاً بسببه. والدرس هنا أن قرار الأسلوب يتخذ في اليوم الأول ويكتب في ملف داخل Git، فلا يتحول كل Pull Request إلى نقاش عن المسافات.

والمشكلة الثانية أخطاء الوحدات، وهي أكثر ما يقابله القادم من Python أو Java في الأسبوع الأول. فقد كتبنا import بدون امتداد كما يفعل المحرر تلقائياً أحياناً:

import { vat } from './price';
console.log(vat(2970));

وعند التشغيل:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/work/src/price' imported from /work/src/noext.ts
    at finalizeResolution (node:internal/modules/esm/resolve:241:11)

والسبب أن وحدات ES في Node.js لا تخمن الامتداد كما كان يفعل require القديم، فالمسار يجب أن يكون كاملاً، وهذا مذكور في توثيق TypeScript في Node.js صراحة. ثم جربنا العكس، أي require داخل ملف .js في مشروع ESM، لأن أغلب الأمثلة القديمة على الإنترنت تستخدمه:

ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and '/work/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.

ورسالة Node.js هنا تشرح نفسها بالكامل: الملف يعامل كـ ES Module لأن امتداده .js والحقل type قيمته module، والحل إما import وإما أن تسمي الملف .cjs. وننصح بقراءة رسائل الخطأ في Node.js حتى آخر سطر، فهي في الإصدارات الحديثة تقول السبب والحل معاً في أغلب الحالات، وقد شرحنا طريقة قراءة رسائل الخطأ عموماً في مقال قراءة رسالة الخطأ Stack Trace.

ماذا يحدث إذا حذفت الحقل type من package.json؟

الآن نجرب ما يفعله كثيرون دون أن ينتبهوا، وهو مشروع بدون الحقل type، لأنه أنشئ بأداة قديمة أو نسخ من مشروع آخر:

npm pkg delete type
node src/cli.ts

والمخرج سوف يكون كما يلي:

(node:337) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///work/src/cli.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /work/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
3 x هيل حب 100 غرام @ 9.90 ر.س
1 x تمر سكري 1 كيلو @ 38.00 ر.س

والملف عمل، ولكن بعد أن حاول Node.js قراءته كـ CommonJS أولاً، وهو النظام القديم الافتراضي، ففشل لأنه وجد import، فأعاد قراءته كـ ES Module، وطبع تحذيراً يقول صراحة إن هذا له تكلفة في الأداء. وهذه الميزة اسمها الكشف التلقائي عن الصيغة Syntax Detection، وجاءت لتجعل الملفات القديمة تعمل، ولكنها ليست شيئاً تعتمد عليه في مشروعك، فالتحذير يظهر في كل تشغيل وفي سجلات الإنتاج، وكل ملف يقرأ مرتين. والحل في نفس الرسالة: أضف "type": "module".

الخطوة الأخيرة: سكربتات npm Scripts في package.json

بعد أن عمل كل شيء نجمع الأوامر في قسم scripts، حتى يكتب كل من في الفريق نفس الأمر، ويستخدمه CI كما هو:

{
  "name": "halala-cli",
  "version": "1.0.0",
  "description": "Prices in halalas: a first Node.js + TypeScript project",
  "scripts": {
    "start": "node src/cli.ts",
    "typecheck": "tsc -p .",
    "test": "node --test",
    "lint": "biome check .",
    "format": "biome format --write ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@biomejs/biome": "^2.6.4",
    "@types/node": "^26.6.6",
    "typescript": "^7.0.2"
  },
  "type": "module"
}

والأمر npm run typecheck يشغل tsc من node_modules/.bin المحلي، أي نفس الإصدار المثبت في ملف القفل، وليس أي tsc مثبت على جهازك عموماً، وهذا هو السبب الذي يجعلك تثبت الأدوات في المشروع نفسه وليس على مستوى النظام بالخيار -g. والسكربتان test وstart لهما اختصار، فتكتب npm test وnpm start دون كلمة run.

هيكل المشروع Project Layout: ما يدخل Git وما لا يدخل

لا يفرض Node.js أي هيكل للمجلدات، وهذا يربك القادم من Java حيث يحدد Maven مكان كل ملف، ولكن العرف المنتشر بسيط، والصورة التالية تبين هيكل مشروع التجربة:

شجرة مجلد halala-cli: package.json وpackage-lock.json وtsconfig.json وbiome.json وsrc فيه price.ts وcli.ts وtest فيه price.test.ts كلها تدخل Git، وnode_modules بحجم 119 MB وdist لا تدخل، وبجانبها شرح دور package.json وملف القفل وtsconfig.json
ملفات الإعداد في الجذر، والكود في src، والاختبارات في test

والقاعدة في الصورة: كل ما تكتبه بيدك أو يحتاجه غيرك ليعيد بناء المشروع يدخل Git، وكل ما يمكن توليده من جديد لا يدخل. فـ node_modules يولد من ملف القفل بالأمر npm ci، وdist يولد من الكود بالبناء، ولذلك يكون ملف .gitignore في أي مشروع Node.js سطرين على الأقل:

node_modules/
dist/

وبعض الفرق تضع الاختبارات بجانب الملف نفسه، مثل src/price.test.ts بجانب src/price.ts، وهذا مقبول ويعمل مع node --test بنفس الطريقة، والمهم أن يكون الاختيار واحداً في كل المشروع. وإذا كبر المشروع إلى عدة حزم في مستودع واحد، مثل الواجهة والخدمة ومكتبة مشتركة للأنواع، فـ npm يدعم ذلك بخاصية Workspaces، وهذا موضوع لا تحتاجه في اليوم الأول.

وأضف حقلاً مفيداً إلى package.json اسمه engines، تكتب فيه إصدار Node.js الذي يعمل عليه المشروع، مثل "node": ">=26"، وملف .nvmrc فيه الرقم 26، فيعرف كل مطور جديد ومدير الإصدارات على جهازه الإصدار المطلوب دون أن يسأل أحداً.

الوحدات Modules: ESM أم CommonJS؟

رأيت في التجربة ثلاث رسائل خطأ لها علاقة بالوحدات، والسبب تاريخي. فعندما ظهر Node.js في 2009 لم يكن للغة نظام وحدات، فاخترع Node.js نظامه الخاص CommonJS بالدالة require والكائن module.exports، وبنيت عليه مئات آلاف الحزم في npm. ثم أضاف المعيار في 2015 نظام الوحدات الرسمي ES Modules بالكلمتين import وexport، وهو الذي تفهمه المتصفحات، وأخذ Node.js سنوات حتى دعمه بشكل كامل، وفي هذه السنوات عاش النظامان معاً بقواعد معقدة. والصورة التالية تلخص كيف يقرر Node.js 26 نوع الملف، والرسائل التي رأيناها:

ثلاثة صناديق: مع type module تعامل ملفات js وts كـ ES Modules وملف cjs يبقى CommonJS، وما رأيناه عند الخطأ: ERR_MODULE_NOT_FOUND بدون امتداد وrequire is not defined في ESM، وبدون الحقل type يعيد Node.js قراءة الملف ويطبع تحذير MODULE_TYPELESS_PACKAGE_JSON، وتحتها قاعدة لمشروع جديد
الحقل type والامتداد يحددان نوع الوحدة، وليس محتوى الملف

والقواعد كما في توثيق الحزم في Node.js هي: ملف .mjs أو .mts هو ES Module دائماً، وملف .cjs أو .cts هو CommonJS دائماً، أما .js و.ts فيحددهما الحقل type في أقرب package.json. والخبر الجيد للقادمين الجدد أن الفجوة بين النظامين صارت صغيرة، فمنذ الإصدارات 22.12 و20.19 يستطيع ملف CommonJS أن يستورد وحدة ES بالدالة require مباشرة ما دامت لا تستخدم await في أعلى الملف، كما يشرح توثيق الوحدات، وصارت الميزة مستقرة في الإصدار 25.4. وهذا أنهى أكبر مشكلة كانت تواجه المشاريع القديمة عندما تحولت مكتبات مشهورة إلى ESM فقط.

والقرار العملي لمشروع جديد سهل: "type": "module"، وimport في كل مكان، والامتداد كاملاً في المسارات النسبية. وإذا ورثت مشروعاً قديماً بـ CommonJS فلا تحوله كله في يوم واحد، فالنظامان يعملان معاً الآن، وتستطيع تحويل الملفات تدريجياً، وابدأ بالملفات الجديدة.

التشغيل في الإنتاج Production: ملفات .ts مباشرة أم البناء إلى JavaScript؟

بعد أن صار Node.js يشغل TypeScript مباشرة، ظهر سؤال جديد في كل فريق: هل نشغل ملفات .ts في الإنتاج أيضاً، أم نبني مجلد dist بـ JavaScript كما كنا نفعل؟ ولكي لا نجيب بالرأي قسنا زمن البدء Startup Time لأداة الهللات بأربع طرق، كل طريقة 20 مرة بالتناوب داخل نفس الـ Container، حتى يتوزع أثر ضجيج الجهاز على الطرق كلها بالتساوي. والبناء إلى dist تم بـ tsc نفسه مع الخيار rewriteRelativeImportExtensions الذي يحول ./price.ts إلى ./price.js في الملفات الخارجة:

npx tsc -p . --noEmit false --outDir dist --rewriteRelativeImportExtensions --allowImportingTsExtensions false
mkdir -p /tmp/ncc; NODE_COMPILE_CACHE=/tmp/ncc node src/cli.ts >/dev/null
a=0; b=0; c=0; d=0
for i in $(seq 1 20); do
  t0=$(date +%s%N); node -e 0
  t1=$(date +%s%N); node dist/src/cli.js >/dev/null
  t2=$(date +%s%N); node src/cli.ts >/dev/null
  t3=$(date +%s%N); NODE_COMPILE_CACHE=/tmp/ncc node src/cli.ts >/dev/null
  t4=$(date +%s%N)
  a=$((a+t1-t0)); b=$((b+t2-t1)); c=$((c+t3-t2)); d=$((d+t4-t3))
done

والمخرج سوف يكون كما يلي:

node -e 0                    129 ms
node dist/src/cli.js         140 ms
node src/cli.ts              274 ms
node src/cli.ts + cache      143 ms

والصورة التالية تبين نفس الأرقام:

أربعة أعمدة أفقية لزمن البدء: node -e 0 بـ 129 ms، وملف JavaScript المبني بـ 140 ms، وملف ts مباشرة بـ 274 ms، وملف ts مع NODE_COMPILE_CACHE بـ 143 ms، مع ملاحظتين: الحذف أضاف حوالي 134 ms، ومع الـ Compile Cache صار الفرق 3 ms
تكلفة حذف الأنواع تدفع عند كل تشغيل، إلا إذا فعلت الـ Compile Cache

وهذه الأرقام تقرأ كما يلي:

  • Node.js فارغ يحتاج 129 ميلي ثانية للبدء داخل هذا الـ Container على Docker Desktop، وهذا رقم أعلى مما ستراه على Linux مباشرة، لذلك المهم هو الفرق بين الصفوف وليس الرقم نفسه.
  • ملف JavaScript المبني أضاف 11 ميلي ثانية فقط فوق Node.js الفارغ، وهي قراءة ملفين وترجمتهما.
  • ملف TypeScript مباشرة أضاف 145 ميلي ثانية، أي أن حذف الأنواع كلف 134 ميلي ثانية أكثر من JavaScript المبنية، لأن Node.js يحمل أداة الحذف ويمرر عليها كل ملف في كل تشغيل.
  • مع متغير البيئة NODE_COMPILE_CACHE انخفض الفرق عن JavaScript المبنية إلى 3 ميلي ثانية، لأن الـ Compile Cache يحفظ نتيجة الترجمة على القرص، ويذكر التوثيق صراحة أنه يشمل وحدات TypeScript، فيدفع الثمن في أول تشغيل فقط.

وقد يتساءل البعض: هل 134 ميلي ثانية مهمة؟ والإجابة تعتمد على نوع البرنامج. فخدمة تعمل أياماً دون إعادة تشغيل تدفع هذا الثمن مرة واحدة عند البدء، فلا يهمها، أما أداة سطر أوامر تعمل مئات المرات في اليوم، أو دالة Serverless تبدأ من الصفر مع كل طلب بارد Cold Start، فالفرق محسوس. وفي مشروع كبير بمئات الملفات يكبر الرقم مع عدد الملفات. ولذلك فإن القرار العملي:

  • التطوير والسكربتات الداخلية: شغل .ts مباشرة، فلا توجد خطوة بناء تنساها، ورسائل الخطأ تشير إلى ملفاتك كما كتبتها.
  • خدمة طويلة العمر: كلا الطريقين مقبول، ومع Docker Image يمكنك أن تبني dist في مرحلة البناء، أو أن تشغل .ts مع NODE_COMPILE_CACHE.
  • أداة توزعها على مستخدمين أو مكتبة تنشرها على npm: ابن إلى JavaScript دائماً، لأن Node.js يرفض حذف الأنواع من الملفات الموجودة داخل node_modules، فالمكتبة التي تنشر ملفات .ts فقط لن تعمل عند من يثبتها.

أخطاء شائعة Common Mistakes في الأسبوع الأول

  • الاعتماد على أن Node.js يفحص الأنواع: رأيت في التجربة فاتورة خاطئة عملت بلا اعتراض، فاجعل tsc خطوة إجبارية في CI.
  • تثبيت الأدوات عالمياً بالخيار -g: كل مطور سيكون لديه إصدار مختلف، والصحيح أن تكون في devDependencies وتشغل من npm run أو npx.
  • npm install في CI وفي Dockerfile: قد يغير ملف القفل ويثبت إصدارات لم يختبرها أحد، واستخدم npm ci.
  • Docker Image بالوسم latest أو بإصدار فردي: node:latest قد يكون إصداراً فردياً لن يدخل LTS، وثبت الإصدار الكامل مثل node:26.13.0-slim.
  • نسخ أمثلة require القديمة إلى مشروع ESM: سوف ترى require is not defined، وحولها إلى import.
  • حذف الامتداد من import: يعمل في بعض أدوات التجميع ويفشل في Node.js، واكتبه دائماً، وmodule: nodenext في tsconfig.json يكشفه قبل التشغيل.
  • استخدام || للقيمة الافتراضية: يستبدل الصفر والنص الفارغ أيضاً، واستخدم ?? عندما يكون الصفر قيمة صحيحة مثل المخزون والخصم.
  • ترك قرار التنسيق بلا ملف إعداد: كل أداة لها افتراضات مختلفة، فاكتب biome.json في اليوم الأول.
  • المبالغ بالريال كأرقام عشرية: احسب بالهللات كأعداد صحيحة، والسبب بالأرقام في مقال الأنواع من هذه المرحلة، وفي مقال الأرقام العشرية Floating Point من الداخل.

قائمة تحقق Checklist لمشروع Node.js جديد

البندالقيمة المقترحةالسبب
إصدار Node.jsآخر Active LTS، أي 26 الآن، في engines و.nvmrcدعم حتى 2029، وكل الفريق على نفس الإصدار
نوع الوحدات"type": "module"المعيار الرسمي، وبدون تحذير إعادة القراءة
TypeScripttsc --noEmit مع strict وerasableSyntaxOnlyالفحص في CI، والتشغيل بالحذف
ملف القفلpackage-lock.json في Git، وnpm ci في CIنفس الشجرة في كل مكان
التنسيق والتدقيقBiome مع biome.jsonأداة واحدة وقرار أسلوب مكتوب
الاختباراتnode --test مع node:assert/strictبدون حزم إضافية
الإنتاجبناء dist أو NODE_COMPILE_CACHEتجنب تكلفة الحذف عند كل بدء
Docker Imagenode:26.13.0-slim بإصدار كاملبناء قابل للتكرار

تدريب عملي

  • أضف إلى price.ts دالة خصم بنسبة مئوية تقرب نصف الهللة إلى الأعلى، واكتب لها اختبارين، أحدهما بخصم صفر، وتأكد أن ?? وليس || هو ما يحمي الصفر.
  • غير module في tsconfig.json إلى esnext مع moduleResolution: bundler، واحذف الامتداد من أحد سطور import، ولاحظ أن tsc يقبله بينما يرفضه Node.js، ثم أرجع الإعداد.
  • أضف enum صغيراً لحالة الطلب، وشغل الملف بـ Node.js، ثم شغل tsc، واقرأ الرسالتين، واستبدل الـ enum بنوع اتحاد Union من النصوص.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • JavaScript في المتصفح وفي Node.js نفس اللغة بمعيار ECMAScript، والفرق في المكتبات حول المحرك، وNode.js هو V8 مع libuv ومكتبات قياسية.
  • للإنتاج اختر آخر إصدار زوجي في Active LTS، وهو Node.js 26 منذ 28 أكتوبر 2026، وثبته بإصدار كامل في Docker.
  • Node.js 26 يشغل TypeScript بحذف الأنواع دون أي فحص، فالملف الذي فيه سعر نصي أعطى فاتورة خاطئة بصمت، وtsc أمسكه برمز خروج 1.
  • أداة لكل خطوة: npm مع ملف القفل وnpm ci، وtsc للفحص، وBiome للتنسيق والتدقيق بملف إعداد مكتوب، وnode --test للاختبارات.
  • "type": "module" والامتداد الكامل في import يمنعان أغلب أخطاء الوحدات التي يقابلها القادمون الجدد.
  • حذف الأنواع أضاف 134 ميلي ثانية لكل تشغيل في تجربتنا، والـ Compile Cache أنزلها إلى 3، فشغل .ts مباشرة في التطوير، وابن أو فعل الـ Cache في الإنتاج.

وفي مرحلة JavaScript وTypeScript نكمل من هنا: الأنواع والقيم والذاكرة والأخطاء، ثم التزامن بالوعود والعمال، ثم خدمة الطلبات الحقيقية بـ Fastify وPostgreSQL، ثم الأخطاء الشائعة وخطة القراءة من المصادر الرسمية:

دليلTypeScript للمطورين: أي الأخطاء تكشفها الأنواع Types؟زرعنا تسعة أخطاء في كود سلة مشتريات وفحصناها بالمترجم tsc بثلاثة إعدادات، لنرى ما يكشفه الوضع الصارم وما يمر رغم كل الأنواع ولماذا تحتاج التحقق وقت التشغيل.دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.

وإذا كنت ما زلت تختار اللغة لمشروعك القادم، فابدأ من مقال كيف تختار لغة البرمجة لمشروعك، وإذا كان فريقك يكتب بأكثر من لغة فاقرأ البرمجة بعدة لغات Polyglot.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Node.js v26.13.0 وnpm 11.20.0 وTypeScript 7.0.2 وBiome 2.6.4 داخل Docker Image الرسمي node:26.13.0-slim.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.