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

الأنواع Types والذاكرة والأخطاء في JavaScript

لماذا ظهر في الفاتورة 29.700000000000003، ولماذا انهارت الخدمة برسالة heap out of memory، وأين يهرب الخطأ من try/catch: قيم JavaScript وذاكرتها وأخطاؤها بقياسات وتشغيلات حقيقية على Node.js 26.

الأنواع Types والذاكرة والأخطاء في JavaScript

في شهر واحد وصلت إلى فريق خدمة الطلبات في شركة ناشئة تبيع القهوة والتمور ثلاث رسائل مختلفة، كلها من نفس الكود المكتوب بـ Node.js وTypeScript. الأولى من قسم المحاسبة: فاتورة يظهر فيها سعر ثلاث علب هيل بقيمة 29.700000000000003 ريالاً بدلاً من 29.70. والثانية من شريك توصيل يرسل أرقام شحنات طويلة، يقول إن الحالة التي ترجعها الخدمة تخص شحنة أخرى، رقمها يختلف عن رقمه في الخانة الأخيرة فقط. والثالثة من المراقبة: الـ Container يعاد تشغيله مرة كل بضع ساعات، وآخر سطر في سجله FATAL ERROR: ... JavaScript heap out of memory، وقبلها بأيام انهارت العملية كلها بسبب استدعاء غير متزامن نسيه أحدهم بدون await. والمطور الذي جاء من Java أو C# يرى الرسائل الثلاث فيسأل سؤالاً منطقياً: أين المترجم الذي كان سيمنع هذا؟ ومن المسؤول عن الذاكرة هنا؟

والإجابة أن الرسائل الثلاث تأتي من ثلاثة أشياء تختلف فيها JavaScript عن أغلب اللغات التي تعرفها: كيف تحمل القيمة نوعها وتتحول إلى نوع آخر دون أن تطلب، وكيف يدير المحرك V8 الذاكرة ومتى يستسلم، وكيف ينتقل الخطأ بين الكود المتزامن والوعود. وهذا المقال من مرحلة JavaScript وTypeScript، ويبني على مقال TypeScript للمطورين الذي قاس ما تكشفه الأنواع قبل التشغيل، فلن نكرر تجربته، وإنما نذهب إلى ما تحت الأنواع: القيم نفسها وقت التشغيل، وتكلفتها في الذاكرة بالبايت، والأخطاء كما يطبعها Node.js فعلاً. وكل رقم ومخرج في المقال من تشغيل حقيقي داخل Container من node:26.13.0-slim بذاكرة محدودة بـ 512 ميجابايت، والملفات كاملة في مجلد lab.

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

  • القيم في JavaScript: الأنواع البسيطة السبعة والكائنات، والنسخ بالقيمة وبالمرجع، وtypeof وأخطاؤه التاريخية.
  • التحويل التلقائي Type Coercion: القواعد التي تجعل [] == false صحيحاً، وأين تظهر في خدمة حقيقية.
  • الرقم الواحد number بدقة 64 بت، ولماذا نحسب بالهللات، ومتى تحتاج BigInt.
  • جامع القمامة Garbage Collector في V8 بجيليه، والحد الأعلى للـ Heap وكيف يتغير بذاكرة الـ Container.
  • تجربة تقيس تكلفة مليون قيمة من كل نوع، وتجربة تسريب ذاكرة حقيقي حتى الانهيار، ثم الإصلاح.
  • الأخطاء: throw وtry/catch والوعود المرفوضة دون معالجة، وcause، وreturn await، والمتغير unknown في TypeScript.
  • الأخطاء الشائعة، وجدول قرار لكل نوع من البيانات.

القيم Values هي التي تحمل النوع

في Java أو C# يحمل المتغير نوعاً ثابتاً، والمترجم يعرف قبل التشغيل أن price من نوع int ولن يكون غير ذلك. أما JavaScript فلغة ديناميكية الأنواع Dynamically Typed، أي أن المتغير مجرد اسم يشير إلى قيمة، والقيمة هي التي تحمل نوعها وقت التشغيل، فيمكن أن يشير نفس المتغير إلى رقم ثم إلى نص ثم إلى كائن. وقد شرحنا الفرق بين الأنظمة الثابتة والديناميكية والقوية والضعيفة في مقال أنماط البرمجة وأنظمة الأنواع، وJavaScript فيها ديناميكية مع تحويل تلقائي كثير، وهذا المزيج هو مصدر أغلب المفاجآت.

وبحسب معيار ECMAScript وشرح MDN لـ هياكل البيانات في JavaScript، توجد سبعة أنواع بسيطة Primitives وهي number وbigint وstring وboolean وundefined وnull وsymbol، وكل ما عدا ذلك كائن Object، بما في ذلك المصفوفات والدوال والتواريخ وMap. والقيم البسيطة غير قابلة للتغيير Immutable، فلا يوجد شيء اسمه تعديل حرف داخل نص، وإنما تنشئ نصاً جديداً، أما الكائنات فقابلة للتغيير، وتنتقل بالمرجع Reference. وشغلنا الملف values.mjs الذي يطبع نتيجة typeof لكل نوع، والمخرج سوف يكون كما يلي:

--- typeof
42           number
"42"         string
42n          bigint
true         boolean
undefined    undefined
null         object
{}           object
[]           object
() => 1      function
Symbol()     symbol
Array.isArray([]) true | null instanceof Object false

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

  • typeof null أعطى object، وهذا خطأ من النسخة الأولى للغة في 1995، بقي حتى اليوم لأن تصحيحه يكسر مواقع قديمة لا حصر لها، وصفحة typeof في MDN تذكر ذلك صراحة. ولذلك لا تفحص الكائن بـ typeof x === 'object' وحده، لأن null سيمر.
  • المصفوفة [] أيضاً object، والفحص الصحيح لها Array.isArray.
  • الدالة لها نوع خاص function، مع أنها كائن من الداخل يمكن أن تضيف إليه خصائص.
  • 42n نوع مستقل bigint، وسوف نعود إليه مع الأرقام الكبيرة.

النسخ بالقيمة Value والنسخ بالمرجع Reference

الفرق بين القيمة البسيطة والكائن يظهر بوضوح عندما تنسخ. فالسطر const copy = { ...order } شائع جداً في كود JavaScript الحديث، والقادم من لغة أخرى يقرؤه كنسخة كاملة من الطلب، ولكنه ينسخ المستوى الأول فقط، والمصفوفة items داخله تبقى نفس المصفوفة في الأصل والنسخة. والكود التالي من نفس الملف يبين ذلك، ويبين البديل structuredClone الذي ينسخ بالعمق Deep Copy، ثم Object.freeze:

const order = { id: 1, items: [{ sku: 'A', qty: 1 }] };
const copy = { ...order };
copy.items[0].qty = 99;
console.log('spread copy changed original qty ->', order.items[0].qty);
const order2 = { id: 2, items: [{ sku: 'B', qty: 1 }] };
const deep = structuredClone(order2);
deep.items[0].qty = 5;
console.log('structuredClone: original qty ->', order2.items[0].qty, '| clone qty ->', deep.items[0].qty);
const frozen = Object.freeze({ price: 10, tags: ['x'] });
try { frozen.price = 0; } catch (e) { console.log('frozen.price = 0 ->', `${e.constructor.name}: ${e.message}`); }
frozen.tags.push('y');
console.log('after push on a frozen object: tags =', frozen.tags);

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

spread copy changed original qty -> 99
structuredClone: original qty -> 1 | clone qty -> 5
frozen.price = 0 -> TypeError: Cannot assign to read only property 'price' of object '#<Object>'
after push on a frozen object: tags = [ 'x', 'y' ]

والصورة التالية تبين ما حدث في السطر الأول، فالكائنان مختلفان، ولكن الخاصية items في كل منهما تشير إلى نفس المصفوفة في الذاكرة:

يمين: جدول القيم البسيطة ونتيجة typeof لكل منها، وnull يعطي object كخطأ تاريخي. يسار: الكائن order ونسخته بالـ Spread يشيران بسهمين إلى نفس العنصر items[0] الذي صار qty فيه 99
القيم البسيطة تنسخ بقيمتها، والكائنات بمرجعها، والـ Spread ينسخ مستوى واحداً

ولاحظ نقطتين في Object.freeze. الأولى أن التعديل رمى TypeError ولم يفشل بصمت، والسبب أن ملفات ES Modules تعمل دائماً في الوضع الصارم Strict Mode، أما في سكربت قديم بدون 'use strict' فالتعديل يتجاهل بصمت ويبقى السعر 10 دون أي رسالة. والثانية أن التجميد سطحي مثل النسخ تماماً، فقد أضفنا عنصراً إلى tags داخل الكائن المجمد بلا اعتراض. وفي خدمة حقيقية يظهر هذا عندما تأخذ كائن إعدادات أو منتجاً من كاش مشترك وتعدله «في نسختك» فتتغير القيمة لكل الطلبات التي بعدك. والقاعدة: إذا كنت ستعدل كائناً لم تنشئه أنت فانسخه بالعمق، أو الأفضل أن تنشئ كائناً جديداً بالقيم التي تريدها.

التحويل التلقائي Type Coercion: عندما يقرر المحرك بدلاً منك

JavaScript لا ترمي خطأ عندما تجمع رقماً مع نص، وإنما تحول أحدهما إلى نوع الآخر بقواعد محددة في المعيار، وهذا هو التحويل التلقائي. والقواعد ليست عشوائية كما توحي النكات المنتشرة عنها، ولكنها كثيرة وغير بديهية، والجدول التالي من تشغيل الملف نفسه:

--- coercion
1 + '2'                            '12' (string)
'3' * '4'                          12 (number)
'10' - 1                           9 (number)
[] + {}                            '[object Object]' (string)
true + 1                           2 (number)
Number('')                         0 (number)
Number(' 12 ')                     12 (number)
Number('12abc')                    NaN (number)
parseInt('12abc')                  12 (number)
[] == false                        true (boolean)
'0' == false                       true (boolean)
null == undefined                  true (boolean)
null === undefined                 false (boolean)
NaN === NaN                        false (boolean)
Object.is(NaN, NaN)                true (boolean)
0 === -0                           true (boolean)

والقاعدة التي تفسر أغلب هذا الجدول أن العامل + له معنيان: الجمع والدمج، فإذا كان أحد الطرفين نصاً بعد تحويله إلى قيمة بسيطة صار دمجاً، ولذلك 1 + '2' يعطي النص '12'. أما بقية العوامل الحسابية مثل * و- فلها معنى واحد هو الحساب، فتحول الطرفين إلى أرقام، ولذلك '10' - 1 يعطي 9. والمقارنة == تتبع خوارزمية اسمها IsLooselyEqual في المعيار، تحول الطرفين خطوة خطوة حتى يصيرا من نفس النوع، فـ [] == false تمر هكذا: false تصبح 0، والمصفوفة الفارغة تصبح نصاً فارغاً ثم 0، فالنتيجة صحيحة. وتشرح MDN هذه الخطوات في صفحة المقارنة والتساوي.

جدولان: العمليات مثل 1 زائد النص 2 تعطي النص 12 والضرب والطرح يحولان إلى رقم وNumber للنص الفارغ تعطي 0 وللنص 12abc تعطي NaN بينما parseInt تعطي 12، والمقارنات مثل المصفوفة الفارغة تساوي false ونص الصفر يساوي false وNaN لا تساوي نفسها وObject.is يعطي true، وتحتها قاعدة التحقق عند الحدود
كل سطر في الجدول خطأ محتمل في خدمة تستقبل بيانات من الخارج

وقد يتساءل البعض: من يكتب [] == false في كود حقيقي؟ والإجابة أن أحداً لا يكتبه بهذا الشكل، ولكن الخدمة تستقبله دون أن تراه. فكل ما يأتي من رابط URL أو من نموذج HTML أو من متغير بيئة أو من ملف CSV يصل نصاً، وأخطر سطرين في الجدول هما Number('') الذي يعطي صفراً، فحقل كمية فارغ في نموذج يصبح طلباً بكمية صفر بدلاً من خطأ، وparseInt('12abc') الذي يأخذ الأرقام في البداية ويتجاهل الباقي، فكمية مكتوبة خطأ مثل 12O بحرف O تمر على أنها 12. والرسالة الأولى التي وصلت للفريق في القصة من نفس العائلة: سعر وصل نصاً من ملف استيراد فجمع مع أسعار أخرى.

والقاعدة العملية: لا تترك المحرك يحول القيم القادمة من الخارج، وإنما حولها بنفسك مرة واحدة عند الحدود بدالة صارمة مثل Number() مع فحص Number.isInteger، أو بمكتبة تحقق Validation، ثم استخدم === في كل الكود، ولا تقارن بـ NaN أبداً لأنها لا تساوي حتى نفسها، واستخدم Number.isNaN. وأداة التدقيق Biome التي ثبتناها في مقال JavaScript لمن يعرف لغة أخرى تمنع == تلقائياً بالقاعدة noDoubleEquals، و TypeScript يرفض أغلب العمليات بين أنواع مختلفة قبل التشغيل، ولكن كلاهما لا يرى البيانات التي تصل وقت التشغيل.

الرقم Number مع BigInt: لماذا نحسب بالهللات؟

في Java لديك int وlong وdouble وBigDecimal، وفي C# لديك decimal للمال. أما في JavaScript فيوجد نوع رقمي واحد للاستخدام اليومي هو number، وهو رقم عشري بدقة 64 بت حسب المعيار IEEE 754، أي نفس double في Java. وهذا يعني شيئين يجب أن تعرفهما من اليوم الأول، والمخرج التالي من نفس الملف يبينهما:

--- numbers
0.1 + 0.2                          0.30000000000000004 (number)
9.90 * 3                           29.700000000000003 (number)
990 * 3 (halalas)                  2970 (number)
MAX_SAFE_INTEGER                   9007199254740991 (number)
MAX_SAFE_INTEGER + 2               9007199254740992 (number)
JSON id 9007199254740993           9007199254740992 (number)
BigInt 9007199254740993n + 2n      9007199254740995 (bigint)
1n + 1                             TypeError: Cannot mix BigInt and other types, use explicit conversions

الأول هو الكسور: 9.90 لا يمكن تمثيلها في النظام الثنائي بدقة، كما أن الثلث لا يمكن كتابته في النظام العشري بدقة، فالمحرك يخزن أقرب قيمة ممكنة، وعند الضرب في 3 يظهر الفرق في الخانة السابعة عشرة: 29.700000000000003. وهذه هي الرسالة الأولى في القصة بالضبط، فأحدهم حسب السعر بالريال كرقم عشري ثم طبعه دون تقريب. وقد شرحنا تمثيل الأرقام العشرية بالبتات في مقال الأرقام العشرية Floating Point من الداخل، والحل في خدمة الطلبات هو ما فعلناه في كل المسار: المبالغ أعداد صحيحة بالهللات، فـ 990 في 3 تساوي 2970 بالضبط، والكسور لا تظهر إلا عند العرض.

والثاني هو الأعداد الصحيحة الكبيرة: لأن الرقم عشري، فالأعداد الصحيحة دقيقة حتى Number.MAX_SAFE_INTEGER، أي 2 أس 53 ناقص 1، وبعدها تبدأ الفجوات، فـ MAX_SAFE_INTEGER + 2 أعطى رقماً زوجياً خاطئاً بدون أي خطأ. وأخطر سطر في المخرج هو JSON.parse، فرقم الشحنة 9007199254740993 في رد JSON من الشريك تحول إلى 9007199254740992، وهذه هي الرسالة الثانية في القصة: الخدمة قرأت الرقم، وضاعت منه الخانة الأخيرة، فسألت عن شحنة أخرى. والقاعدة أن كل معرف يأتي من نظام آخر يخزن نصاً وليس رقماً، حتى لو كان كله أرقاماً، فأنت لا تجمع أرقام الشحنات ولا تضربها.

وعندما تحتاج حساباً حقيقياً على أعداد صحيحة كبيرة، فاللغة فيها النوع BigInt منذ ES2020، تكتبه بحرف n في نهاية الرقم، وهو دقيق بأي حجم. ولكن لاحظ السطر الأخير: 1n + 1 رمى TypeError، لأن اللغة ترفض خلط bigint مع number تلقائياً، وهذا التحويل الوحيد تقريباً الذي ترفضه JavaScript، والسبب أن أحدهما سيفقد دقته حتماً. والصورة التالية تجمع الحالات الثلاث:

ثلاثة صناديق: الكسور حيث 0.1 زائد 0.2 و9.90 في 3 تعطي قيماً بخانات زائدة، والأعداد الكبيرة حيث MAX_SAFE_INTEGER زائد 2 ورقم JSON يفقدان الخانة الأخيرة، والحل بالهللات وBigInt مع خطأ خلط الأنواع، وتحتها خط أعداد يبين منطقة الدقة حتى 2 أس 53 ثم الفجوات
نوع رقمي واحد: دقيق للأعداد الصحيحة حتى 2 أس 53، وتقريبي للكسور دائماً

وسوف تقابل هذا الموضوع مرة أخرى في مقال الخدمة الحقيقية، فمكتبة pg التي تتصل بـ PostgreSQL ترجع الأعمدة من نوع bigint والأعمدة numeric كنصوص وليس كأرقام، لنفس السبب تماماً، ولهذا اختار تصميم الخدمة int للهللات، فأكبر قيمة له أكثر من 21 مليون ريال في الطلب الواحد، وهو أكثر من كاف لمتجر قهوة.

ماذا تضيف TypeScript هنا؟ أنواع وقت الكتابة Compile Time فقط

كل ما سبق يحدث وقت التشغيل، وTypeScript كما شرحنا في مقالها تحذف كل الأنواع قبل التشغيل، فلا يبقى منها شيء يحميك من نص وصل من الشبكة. ولكنها تغير شيئاً مهماً: داخل الكود الذي تكتبه أنت، يرفض المترجم 1 + '2' إذا كان أحد الطرفين معرفاً كرقم، ويرفض المقارنة بين نص ورقم، ويجبرك على التعامل مع null وundefined في الوضع الصارم. فالحدود واضحة: TypeScript تحمي الكود من نفسه، والتحقق وقت التشغيل يحمي الكود من العالم الخارجي، وكلاهما ضروري. والمثال الذي يربط الأنواع بالأخطاء مباشرة هو متغير catch، وسوف نعود إليه في قسم الأخطاء.

وتوجد حيلة مفيدة لمسألة الهللات تسمى الأنواع المميزة Branded Types، تعرف فيها نوعاً مثل type Halalas = number & { __brand: 'Halalas' }، فلا يقبل المترجم أن تمرر رقماً بالريال إلى دالة تنتظر هللات دون تحويل صريح. وهي لا تكلف شيئاً وقت التشغيل لأنها تحذف مع بقية الأنواع، ولكنها تجعل خطأ الوحدات يظهر في المحرر بدلاً من الفاتورة.

JSON على الحدود Boundaries: ما الذي يضيع في الطريق؟

كل قيمة في خدمة الطلبات تعبر الحدود بصيغة JSON مرتين على الأقل: من العميل إلى الخدمة، ومن الخدمة إلى قاعدة البيانات أو إلى خدمة أخرى، وJSON لا يعرف إلا ستة أشياء: الكائن والمصفوفة والنص والرقم وtrue وfalse وnull. فماذا يحدث لبقية أنواع JavaScript؟ كتبنا الملف json.mjs الذي ينشئ طلباً فيه تاريخ وقيمة undefined وSet وMap وNaN، ثم يحوله إلى JSON ويقرؤه مرة أخرى:

const order = { id: 7, createdAt: new Date('2026-10-31T09:30:00Z'), note: undefined, total: 3416,
  tags: new Set(['gift']), lines: new Map([[3, 2]]), ratio: NaN };
const text = JSON.stringify(order);
console.log('stringify ->', text);
const back = JSON.parse(text);
console.log('createdAt type after parse ->', typeof back.createdAt, '| has note ->', 'note' in back, '| ratio ->', back.ratio);
try { JSON.stringify({ shipmentId: 9007199254740993n }); } catch (e) { console.log('BigInt ->', `${e.constructor.name}: ${e.message}`); }
console.log('reviver ->', JSON.parse(text, (k, v) => k === 'createdAt' ? new Date(v) : v).createdAt instanceof Date);

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

stringify -> {"id":7,"createdAt":"2026-10-31T09:30:00.000Z","total":3416,"tags":{},"lines":{},"ratio":null}
createdAt type after parse -> string | has note -> false | ratio -> null
BigInt -> TypeError: Do not know how to serialize a BigInt
reviver -> true

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

  • التاريخ صار نصاً بصيغة ISO بتوقيت UTC، وعند القراءة بقي نصاً، فالكود الذي يستدعي back.createdAt.getTime() سوف يرمي TypeError. والحل إما دالة الإحياء Reviver التي تراها في السطر الأخير، أو الأفضل أن يحول طبقة التحقق عند الحدود كل حقل إلى نوعه مرة واحدة.
  • الحقل note بقيمة undefined اختفى تماماً من النص، وهذا مقصود في المعيار، ولكنه يعني أن «الحقل غير موجود» و«الحقل موجود وفارغ» صارا شيئاً واحداً، فإذا كان الفرق مهماً في الـ API فاستخدم null صراحة.
  • Set وMap صارا كائنين فارغين {} بصمت، أي أن بيانات حقيقية ضاعت دون أي خطأ، ويجب أن تحولهما بنفسك إلى مصفوفات قبل الإرسال.
  • NaN صار null، وهذا يخفي خطأ حساب حدث في مكان آخر.
  • BigInt هو الوحيد الذي رفض صراحة برمي TypeError، لنفس السبب الذي جعله يرفض الخلط مع number: لا يوجد تحويل لا يفقد شيئاً.

وهذا يفسر لماذا يحتاج كل نظام JavaScript جاد إلى مخطط Schema عند الحدود، كما في مكتبة Zod التي استخدمناها في مقال TypeScript، أو مخططات JSON Schema التي يستخدمها إطار Fastify في مقال الخدمة الحقيقية: الأنواع داخل الكود لا تعرف شيئاً عن هذه التحويلات، والمخطط هو المكان الوحيد الذي يقول «هذا الحقل تاريخ، وهذا عدد صحيح موجب، وهذا لا يجوز أن يغيب».

من يدير الذاكرة؟ جامع القمامة Garbage Collector في V8

في C تحجز الذاكرة وتحررها بنفسك، وفي Rust يحدد المترجم متى تحرر بقواعد الملكية Ownership، أما في JavaScript فلا توجد أي دالة لتحرير الذاكرة، والمحرك هو الذي يقرر بجامع القمامة Garbage Collector، كما في Java وC# وGo. وقد قارنا هذه الطرق الثلاث في مقال إدارة الذاكرة يدوياً أم Garbage Collector أم Ownership. والقاعدة التي يعمل بها الجامع هي الوصول Reachability: كل قيمة يمكن الوصول إليها من الجذور، أي المتغيرات العامة ومكدس الاستدعاءات الحالي وما يشير إليه كل منهما، تبقى حية، وكل ما لا يمكن الوصول إليه يجمع. وهذا يعني أن «التسريب» في JavaScript ليس ذاكرة نسيت أن تحررها، وإنما مرجع نسيت أن تحذفه، فالجامع لا يستطيع أن يجمع شيئاً ما زال كاش عام يشير إليه.

والمحرك V8 يقسم الـ Heap إلى جيلين، والفكرة مبنية على ملاحظة تجريبية اسمها الفرضية الجيلية Generational Hypothesis: أغلب الكائنات تموت صغيرة. فالكائن المؤقت الذي تنشئه لمعالجة طلب واحد ينتهي دوره بعد ميلي ثوان، ولذلك يشرح فريق V8 في تدوينة جامع القمامة Orinoco أن الجامع يتعامل مع الجيلين بطريقتين:

  • الجيل الصغير Young Generation: كل كائن جديد يولد هنا، في مساحة صغيرة، والجمع فيه اسمه Scavenge، وهو سريع ومتكرر، لأنه ينسخ الكائنات الحية القليلة فقط إلى مكان آخر ويعتبر الباقي كله فارغاً.
  • الجيل القديم Old Generation: الكائن الذي ينجو من جمعين في الجيل الصغير ينتقل إلى هنا، والجمع فيه اسمه Mark-Compact، يمر على كل الكائنات الحية ويعلمها ثم يضغط الذاكرة، وهو أغلى بكثير، ولكن V8 يقوم بأغلب العمل بالتوازي على خيوط مساعدة وأثناء عمل كودك.

ويمكنك أن ترى هذا بعينك بالخيار --trace-gc، وهذه أول أسطر من برنامج صغير ينشئ 300 ألف كائن ويحتفظ بها:

[36:0x3eeda000]       65 ms: Scavenge 4.8 (5.4) -> 4.6 (6.4) MB, pooled: 0.0 MB, 9.33 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[36:0x3eeda000]       69 ms: Scavenge 4.8 (6.4) -> 4.8 (7.1) MB, pooled: 0.0 MB, 2.21 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[36:0x3eeda000]      101 ms: Scavenge 6.9 (8.6) -> 6.2 (9.1) MB, pooled: 0.0 MB, 17.31 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[36:0x3eeda000]      129 ms: Scavenge 7.2 (10.0) -> 7.0 (11.3) MB, pooled: 0.0 MB, 16.21 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure; 

وكل سطر يقرأ هكذا: بعد 69 ميلي ثانية من البدء حدث جمع Scavenge، وكان الحجم المستخدم 4.8 ميجابايت من 6.4 محجوزة، وبعد الجمع بقي 4.8، أي أن كل شيء تقريباً كان حياً لأن البرنامج يحتفظ بالكائنات في مصفوفة، واستغرق الجمع 2.21 ميلي ثانية. والسبب allocation failure ليس خطأ، وإنما يعني أن المساحة الصغيرة امتلأت فاحتاج المحرك أن يجمع قبل أن يحجز. والقيمة mu هي نسبة الوقت الذي عمل فيه كودك وليس الجامع، وقيمة 1.000 تعني أن الجامع لم يأخذ وقتاً يذكر حتى الآن، وسوف ترى في تجربة التسريب كيف تنخفض إلى الصفر تقريباً.

الحد الأعلى للـ Heap ولماذا يهمك في Container

V8 لا يستخدم كل ذاكرة الجهاز، وإنما يضع حداً أعلى للـ Heap، فإذا وصل إليه وفشل الجمع في تحرير مساحة كافية أوقف العملية كلها برسالة JavaScript heap out of memory، وهذا ما رأيناه في الرسالة الثالثة من القصة. والسؤال المهم: كم هذا الحد؟ فشغلنا Node.js في Containers بأحجام ذاكرة مختلفة، وطبعنا heap_size_limit من الوحدة v8:

for m in none 256m 1g 2g; do
  if [ $m = none ]; then a=""; else a="--memory=$m"; fi
  printf "%s: " $m
  docker run --rm $a node:26.13.0-slim node -p "(require('v8').getHeapStatistics().heap_size_limit/1048576).toFixed(0)+' MB'"
done

والمخرج سوف يكون كما يلي، ومعه القيمتان من داخل Container التجربة بذاكرة 512 ميجابايت:

none: 4192 MB
256m: 262 MB
1g: 536 MB
2g: 1072 MB
default limit 268 MB
with --max-old-space-size=64: 76 MB
فوق: الجيل الصغير بجمع Scavenge سريع مع سطر من trace-gc، والجيل القديم بجمع Mark-Compact مع رسالة FATAL ERROR heap out of memory. تحت: الحد الأعلى للـ Heap حسب ذاكرة الـ Container: 262 MB مع 256m و268 MB مع 512m و1072 MB مع 2g و4192 MB بدون حد، ومع max-old-space-size=64 صار 76 MB
Node.js يقرأ حد ذاكرة الـ Container ويحسب حد الـ Heap منه

وهذه الأرقام تقول إن Node.js 26 يقرأ حد الذاكرة المفروض على الـ Container ويضع حد الـ Heap عند نصفه تقريباً، فـ 2 جيجابايت أعطت 1072 ميجابايت، و1 جيجابايت أعطت 536، أما بدون حد فأخذ الحد الافتراضي 4192 ميجابايت مهما كانت ذاكرة الجهاز. والنصف الآخر ليس ضائعاً، فالعملية تحتاج ذاكرة خارج الـ Heap للـ Buffers والمكتبات الأصلية ومكدسات الخيوط والكود المترجم. ومع 256 ميجابايت لاحظ أن الحد صار 262، وهو أكثر من النصف، فالمحرك لا ينزل تحت حد أدنى معين. والخيار --max-old-space-size يضبط حجم الجيل القديم وحده بالميجابايت، ولذلك صار الحد الكلي 76 وليس 64، فالفرق هو الجيل الصغير.

والدرس العملي أن الإعدادات القديمة التي تجدها في كثير من ملفات Dockerfile، مثل --max-old-space-size=4096 في Container بذاكرة 1 جيجابايت، أخطر من عدم وجودها، لأنها تسمح للـ Heap بأن يكبر فوق ذاكرة الـ Container نفسها، فلا ترى رسالة V8 الواضحة، وإنما يقتل نظام التشغيل العملية بالـ OOM Killer فجأة دون أي سطر في السجل. فإما أن تترك Node.js يحسب الحد بنفسه، وإما أن تضبطه بقيمة أقل من ذاكرة الـ Container بهامش واضح.

التجربة الأولى Lab: كم تكلف مليون قيمة في الذاكرة؟

القادم من Java يعرف أن int أربعة بايتات وأن الكائن له رأس، فما الأرقام في JavaScript؟ كتبنا ملفاً ينشئ مليون عنصر من كل نوع ويقيس الفرق في الذاكرة، ويستدعي الجامع يدوياً قبل القياس وبعده، وهذا يحتاج الخيار --expose-gc الذي يجعل الدالة gc() متاحة:

// Section B: how much heap do one million values cost?  node --expose-gc heap.mjs
const mb = (b) => (b / 1024 / 1024).toFixed(1) + ' MB';
const used = () => { globalThis.gc(); globalThis.gc(); const m = process.memoryUsage(); return m.heapUsed + m.arrayBuffers; };   // ArrayBuffers live outside the JS heap
const N = 1_000_000;
const measure = (label, make) => {
  const before = used();
  const keep = make();
  const after = used();
  console.log(label.padEnd(40), mb(after - before).padStart(9), ((after - before) / N).toFixed(1).padStart(6), 'bytes/item');
  return keep;
};
let k;
k = measure('array of 1M small ints', () => Array.from({ length: N }, (_, i) => i));
k = measure('array of 1M doubles', () => Array.from({ length: N }, (_, i) => i + 0.5));
k = measure('Int32Array(1M) (outside the heap)', () => new Int32Array(N));
k = measure('1M objects {id, price, qty}', () => Array.from({ length: N }, (_, i) => ({ id: i, price: 990, qty: 1 })));
k = measure('1M strings "sku-<n>"', () => Array.from({ length: N }, (_, i) => 'sku-' + i));
k = measure('Map with 1M int keys', () => { const m = new Map(); for (let i = 0; i < N; i++) m.set(i, i); return m; });
k = null;
const v8 = await import('node:v8');
console.log('heap_size_limit', mb(v8.getHeapStatistics().heap_size_limit));

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

array of 1M small ints                      7.6 MB    8.0 bytes/item
array of 1M doubles                         7.6 MB    8.0 bytes/item
Int32Array(1M) (outside the heap)           3.8 MB    4.0 bytes/item
1M objects {id, price, qty}                53.4 MB   56.0 bytes/item
1M strings "sku-<n>"                       38.1 MB   39.9 bytes/item
Map with 1M int keys                       27.1 MB   28.4 bytes/item
heap_size_limit 268.0 MB

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

أعمدة أفقية لتكلفة العنصر الواحد: مصفوفة أعداد صحيحة صغيرة 8 بايت ومصفوفة أعداد عشرية 8 بايت وInt32Array 4 بايت خارج الـ Heap ونص قصير 39.9 بايت وMap 28.4 بايت وكائن بثلاثة حقول 56 بايت، مع شرح أن الكائن له رأس فيه Hidden Class وأن Int32Array يظهر في arrayBuffers
الكائن الصغير يكلف سبعة أضعاف الرقم في المصفوفة

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

  • الأعداد الصحيحة الصغيرة 8 بايتات: V8 يخزن العدد الصحيح الصغير داخل الخانة نفسها مباشرة، ويسميه Smi اختصاراً لـ Small Integer، فلا يوجد كائن لكل رقم. والخانة في بناء Node.js الرسمي 8 بايتات، أما Chrome فيستخدم ضغط المؤشرات Pointer Compression فتصبح 4.
  • الأعداد العشرية أيضاً 8 بايتات: لأن V8 يلاحظ أن المصفوفة كلها أرقام عشرية فيخزنها كمصفوفة double متصلة دون كائن لكل رقم، وهذا ما يسميه فريق V8 أنواع العناصر Elements Kinds. ولو خلطت في نفس المصفوفة رقماً ونصاً لانتقلت إلى النوع العام الأبطأ والأكبر.
  • الكائن الصغير 56 بايتاً: ثلاثة حقول قيمتها 24 بايتاً فقط، والباقي رأس الكائن: مؤشر إلى شكله المخفي Hidden Class الذي يصف أسماء الحقول ومواقعها، ومؤشر للخصائص الإضافية، ومؤشر للعناصر، ثم الخانة في المصفوفة التي تشير إلى الكائن. فمليون طلب في الذاكرة كمصفوفة كائنات يكلف سبعة أضعاف نفس الأرقام في مصفوفة أرقام.
  • النص القصير 40 بايتاً تقريباً: رأس النص مع طوله وهاشه، ثم الحروف مقربة إلى حجم محاذاة، ثم الخانة في المصفوفة.
  • الـ Map 28 بايتاً لكل زوج: المفتاح والقيمة ومؤشر السلسلة داخل جدول الهاش، مع مساحة احتياطية لأن الجدول يكبر بالمضاعفة.

والشكل المخفي Hidden Class الذي ذكرناه في الكائن له أثر على السرعة أيضاً وليس على الحجم فقط. فـ V8 يعطي كل كائن شكلاً بحسب أسماء حقوله وترتيب إضافتها، والكائنات التي تشترك في نفس الشكل تشترك في نفس الوصف، فيصل الكود المترجم إلى الحقل price بموقع ثابت دون بحث. أما إذا أنشأت بعض الطلبات بالحقول id ثم price، وبعضها بالترتيب المعاكس، أو أضفت حقلاً إلى بعض الكائنات بعد إنشائها، فكل ترتيب يصبح شكلاً مختلفاً، والدالة التي تمر على هذه الكائنات تصبح أبطأ لأنها ترى أشكالاً كثيرة. والقاعدة العملية بسيطة: أنشئ الكائنات من نفس النوع بنفس الحقول وبنفس الترتيب، في المنشئ أو في دالة واحدة، ولا تضف حقولاً لاحقاً، ولا تستخدم delete على كائنات في مسار ساخن، وضع null بدلاً منها.

والفائدة العملية من هذه الأرقام أنك تستطيع الآن أن تقدر حجم الكاش قبل أن تكتبه: مئة ألف منتج ككائنات بعشرة حقول وأسماء عربية ستكلف عشرات الميجابايتات، وهذا جزء كبير من Heap حده 268 ميجابايت في Container بذاكرة 512.

مشكلة واجهناها: Int32Array بصفر بايت

في التشغيل الأول للتجربة كانت دالة القياس تعتمد على heapUsed وحده، فظهر السطر التالي:

Int32Array(1M)                              0.0 MB    0.0 bytes/item

ومصفوفة بمليون عدد من 4 بايتات لا يمكن أن تكون مجانية، والسبب أن بيانات المصفوفات المكتوبة Typed Arrays ومعها Buffer في Node.js تحجز خارج الـ Heap الذي يديره الجامع، ولا يظهر في heapUsed إلا كائن صغير يشير إليها. والقيمة الصحيحة تظهر في الحقل arrayBuffers من process.memoryUsage()، فعدلنا الدالة لتجمع الحقلين، فظهر الرقم الصحيح 4 بايتات بالضبط. وهذه ملاحظة مهمة للمراقبة أيضاً: خدمة ترفع ملفات أو تعالج صوراً قد يكون أغلب ذاكرتها خارج heapUsed، فراقب rss وexternal وarrayBuffers معه، وإلا ستبحث عن تسريب في المكان الخطأ.

التجربة الثانية Lab: تسريب ذاكرة Memory Leak حتى الانهيار

الآن نعيد إنتاج الرسالة الثالثة من القصة. ففي الخدمة كان هناك كاش بسيط لملفات العملاء: كل طلب يقرأ ملف العميل ويضعه في Map عامة بمفتاح رقم العميل، حتى لا يعيد قراءته في الطلب التالي. والفكرة صحيحة، ولكن الكاش لا يحذف شيئاً أبداً، ومع كل عميل جديد يكبر. والملف التالي يحاكي ذلك، ويطبع حجم الـ Heap كل خمسة آلاف طلب، ونشغله بحد صغير 64 ميجابايت حتى نصل إلى الانهيار بسرعة بدلاً من ساعات:

// Section C: a per-request cache that never forgets.  node --max-old-space-size=64 leak.mjs [leak|lru]
const mode = process.argv[2] || 'leak';
const mb = (b) => (b / 1024 / 1024).toFixed(1);
const cache = new Map();
const LIMIT = 1000;
function handleRequest(i) {
  const key = `user-${i}`;                 // every request has a new key
  const profile = { id: i, name: 'عميل ' + i, history: new Array(200).fill(i) };
  cache.set(key, profile);
  if (mode === 'lru' && cache.size > LIMIT) cache.delete(cache.keys().next().value);   // Map keeps insertion order
}
for (let i = 1; i <= 2_000_000; i++) {
  handleRequest(i);
  if (i % 5_000 === 0) console.log(`${mode} requests=${i} cache=${cache.size} heapUsed=${mb(process.memoryUsage().heapUsed)} MB`);
  if (mode === 'lru' && i === 200_000) break;
}
console.log('done');

والمخرج في وضع التسريب سوف يكون كما يلي:

leak requests=5000 cache=5000 heapUsed=12.9 MB
leak requests=10000 cache=10000 heapUsed=22.2 MB
leak requests=15000 cache=15000 heapUsed=30.1 MB
leak requests=20000 cache=20000 heapUsed=39.5 MB
leak requests=25000 cache=25000 heapUsed=47.3 MB
leak requests=30000 cache=30000 heapUsed=55.9 MB
<--- Last few GCs --->
[45:0x18581000]     1475 ms: Mark-Compact 62.3 (64.9) -> 62.3 (64.9) MB, pooled: 0.0 MB, 78.30 / 0.00 ms (average mu = 0.063, current mu = 0.000) allocation failure; GC in old space requested
[45:0x18581000]     1620 ms: Mark-Compact (reduce) 62.3 (64.9) -> 62.3 (64.4) MB, pooled: 0.0 MB, 145.22 / 0.00 ms (average mu = 0.021, current mu = 0.000) last resort; GC in old space requested
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
Aborted (core dumped)
exit code: 134 after 22 s

وهذا المخرج يحتاج قراءة من الأسفل إلى الأعلى، كما تقرأ أي انهيار:

  • رمز الخروج 134: يعني أن العملية أوقفت بالإشارة SIGABRT، أي 128 زائد 6، وهذا ما يراه Docker أو Kubernetes، فيعيد تشغيل الـ Container. والـ 22 ثانية أغلبها كتابة ملف الـ Core Dump، أما الانهيار نفسه فحدث بعد 1.6 ثانية بحسب توقيت الجامع.
  • Ineffective mark-compacts near heap limit: الجامع حاول عدة مرات في الجيل القديم، وفي كل مرة لم يحرر شيئاً تقريباً، فقرر أن الاستمرار بلا فائدة. وهذه هي العبارة التي تفرق تسريب الذاكرة عن ذروة مؤقتة.
  • 62.3 -> 62.3: قبل الجمع وبعده نفس الحجم، أي أن كل شيء في الـ Heap حي، لأن الكاش العام يشير إليه.
  • last resort وcurrent mu = 0.000: الجامع جرب «الملاذ الأخير» بضغط كل شيء، والقيمة mu صفر تعني أن البرنامج في آخر لحظاته لم يعمل أي شيء غير جمع القمامة. وفي خدمة حقيقية هذه المرحلة تظهر قبل الانهيار كزمن استجابة يرتفع بشكل غريب، لأن كل طلب ينتظر الجامع.
  • أسطر heapUsed: تكبر بخطوة ثابتة تقريباً، حوالي 9 ميجابايت لكل خمسة آلاف عميل، أي قرابة 1.9 كيلوبايت لكل ملف عميل، وهذا هو شكل التسريب في أي رسم مراقبة: خط صاعد لا ينزل بعد الجمع.

والإصلاح هو أن يكون للكاش حد. والطريقة الأبسط في JavaScript تستفيد من أن Map تحفظ ترتيب الإدخال، فعندما يتجاوز الحجم ألف عنصر نحذف أقدم مفتاح، وهذا هو السطر الذي يعمل في وضع lru. والمخرج بعد مئتي ألف طلب، أي أكثر من ستة أضعاف ما قتل النسخة الأولى:

lru requests=5000 cache=1000 heapUsed=12.8 MB
lru requests=10000 cache=1000 heapUsed=7.2 MB
lru requests=15000 cache=1000 heapUsed=16.5 MB
...
lru requests=195000 cache=1000 heapUsed=12.8 MB
lru requests=200000 cache=1000 heapUsed=8.4 MB
done
رسم خطي لـ heapUsed مقابل عدد الطلبات حتى 200 ألف: الخط الأحمر للكاش بلا حد يصعد بسرعة حتى 55.9 MB عند 30 ألف طلب ثم ينهار برسالة heap out of memory ورمز 134 قرب الحد المتقطع 64 MB، والخط الأخضر للكاش بحد 1000 عنصر يتذبذب بين 6.7 و20.8 MB حتى النهاية
التسريب خط يصعد ولا ينزل، والكاش المحدود منشار يصعد وينزل مع كل جمع

ولاحظ شكل الخط الأخضر: منشار يصعد ثم ينزل، بين 6.7 و20.8 ميجابايت، وهذا هو الشكل الصحي لأي خدمة بجامع قمامة، فالقمامة تتراكم حتى يأتي الجمع فيحررها. والكاش هنا ليس LRU حقيقياً، لأن العنصر الذي يقرأ كثيراً لا يتقدم في الترتيب، ولكنه يكفي لإثبات الفكرة، وفي الإنتاج تستخدم مكتبة كاش مجربة بحد للعدد وبمدة صلاحية TTL.

وقد يتساءل البعض: أليس WeakMap هو الحل الجاهز لهذا؟ والإجابة أنه حل لمشكلة أخرى. فـ WeakMap لا تمنع الجامع من جمع المفتاح، فإذا لم يبق أي مرجع للكائن المفتاح في مكان آخر اختفى الزوج من الخريطة تلقائياً، وهذا مفيد عندما تريد إلحاق بيانات إضافية بكائن يملكه غيرك، مثل كائن الطلب الحالي. ولكن مفاتيحها يجب أن تكون كائنات وليس نصوصاً، فلا يمكن أن تكون 'user-42' مفتاحاً فيها، ولا يمكنك أن تعرف حجمها أو تمر على عناصرها، فهي ليست كاشاً.

وعندما يحدث تسريب حقيقي في الإنتاج فلن تعرف مكانه من رسالة الانهيار، والأداة الصحيحة هي لقطة الـ Heap Snapshot، تأخذ اثنتين بفارق زمني وتقارن أي نوع من الكائنات زاد عدده، كما يشرح دليل استخدام Heap Snapshot في توثيق Node.js، ويمكن أن يأخذها Node.js تلقائياً قبل الانهيار بالخيار --heapsnapshot-near-heap-limit. وقد شرحنا أدوات التصحيح عموماً في مقال قراءة رسالة الخطأ والتصحيح Debugging.

ماذا يحدث إذا شغلت نفس التسريب بدون حد Heap Limit؟

في التجربة وضعنا الحد 64 ميجابايت بأنفسنا، فماذا يحدث في الإنتاج حيث لا يضع أحد هذا الخيار؟ الجدول في قسم الحد الأعلى يجيب: في Container بذاكرة 512 ميجابايت يصبح الحد 268 ميجابايت، فيصل نفس الكاش إليه بعد حوالي 140 ألف عميل بنفس معدل 1.9 كيلوبايت للعميل، وهذا قد يستغرق ساعات أو أياماً بحسب الطلبات، ولذلك ظهرت المشكلة في القصة كإعادة تشغيل «كل بضع ساعات». أما في Container بلا حد ذاكرة على سيرفر كبير فالحد 4192 ميجابايت، والتسريب نفسه يعيش أسابيع، ويأخذ معه ذاكرة الخدمات الأخرى على نفس السيرفر قبل أن ينهار. والدرس أن الحد الصغير يجعل المشكلة تظهر بسرعة وبرسالة واضحة، والحد الكبير يخفيها حتى تصبح مشكلة لغيرك، ولذلك ضع حد ذاكرة لكل Container، وراقب heapUsed بعد الجمع وليس قبله.

الأخطاء Errors في JavaScript: throw والوعود Promises

نظام الأخطاء في JavaScript قريب من Java وC# من حيث الشكل: throw وtry وcatch وfinally، وكلاس Error مع أنواع فرعية مثل TypeError وSyntaxError وRangeError. ولكن يوجد فرقان كبيران: لا توجد استثناءات معلنة Checked Exceptions، فلا تعرف من توقيع الدالة ما الذي قد ترميه، وجزء كبير من الأخطاء لا يرمى في نفس المكدس أصلاً، وإنما ينتقل داخل وعد مرفوض. وقد قارنا طرق اللغات في التعامل مع الأخطاء في مقال كيف تتعامل لغات البرمجة مع الأخطاء، وهنا سوف نرى الحالات كما يطبعها Node.js 26 فعلاً من الملف errors.mjs:

const cases = {
  string() { throw 'out of stock'; },
  typeerror() { const order = undefined; return order.items.length; },
  json() { JSON.parse('{"qty": 3,}'); },
  cause() {
    try { JSON.parse('{bad'); }
    catch (err) { throw new Error('cannot read order 42', { cause: err }); }
  },
  async rejection() {
    const save = async () => { throw new Error('db is down'); };
    save();                        // no await, no catch
    console.log('request finished as if all was fine');
  },
  async trywithoutawait() {
    const save = async () => { throw new Error('db is down'); };
    try { return save(); } catch { console.log('never reached'); }
  },
  async trywithawait() {
    const save = async () => { throw new Error('db is down'); };
    try { return await save(); } catch (e) { console.log('caught:', e.message); }
  },
  async any() {
    await Promise.any([Promise.reject(new Error('mirror A')), Promise.reject(new Error('mirror B'))]);
  },
};

ارم كائن Error وليس نصاً

اللغة تسمح لك أن ترمي أي قيمة، نصاً أو رقماً أو كائناً عادياً، وهذا ما يحدث عندما ترمي نصاً:

node:internal/modules/run_main:111
    triggerUncaughtException(
    ^
out of stock
(Use `node --trace-uncaught ...` to show where the exception was thrown)

Node.js v26.13.0
exit code: 1

لا يوجد مكدس استدعاءات Stack Trace، ولا يوجد اسم ملف أو رقم سطر، فقط النص، والسبب أن المكدس يسجل عند إنشاء كائن Error وليس عند throw. وقارنه بالخطأ الذي يرميه المحرك نفسه عندما تقرأ خاصية من undefined:

file:///lab/errors.mjs:5
  typeerror() { const order = undefined; return order.items.length; },
                                                      ^

TypeError: Cannot read properties of undefined (reading 'items')
    at Object.typeerror (file:///lab/errors.mjs:5:55)
    at file:///lab/errors.mjs:35:15

Node.js v26.13.0
exit code: 1

هنا السطر والعمود وعلامة ^ تحت المكان بالضبط، واسم الخاصية التي حاولت قراءتها. وهذه الرسالة Cannot read properties of undefined هي أكثر خطأ ستراه في حياتك مع JavaScript، وفي أغلب الحالات سببه بيانات جاءت بشكل غير المتوقع، والعلاج إما التحقق عند الحدود، وإما ?. عندما يكون غياب القيمة طبيعياً. والقاعدة لكودك: ارم دائماً new Error(...) أو كلاساً يرث منه، ولا ترم نصاً أبداً.

سلسلة الأسباب Error Cause وكلاسات الأخطاء الخاصة

عندما تمسك خطأ من طبقة منخفضة وترمي خطأ بمعنى أوضح لطبقتك، لا تخف الخطأ الأصلي، فمنذ ES2022 يأخذ Error خياراً اسمه cause، وNode.js يطبع السلسلة كاملة:

Error: cannot read order 42
    at Object.cause (file:///lab/errors.mjs:9:25)
    at file:///lab/errors.mjs:35:15
    ... 2 lines matching cause stack trace ...
  [cause]: SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
      at JSON.parse (<anonymous>)
      at Object.cause (file:///lab/errors.mjs:8:16)
      at file:///lab/errors.mjs:35:15
}

فالسطر الأول يقول ما حدث بلغة العمل، «لا أستطيع قراءة الطلب 42»، والـ [cause] يقول السبب التقني بموقعه، وهذا ما تريده في السجلات. ولأخطاء العمل التي يجب أن تعاملها الطبقة الأعلى بشكل مختلف، مثل نفاد المخزون الذي يتحول إلى رد 409 في الـ API، اكتب كلاساً خاصاً يرث من Error، فالحالة custom في الملف ترمي OutOfStockError بحقل productId، وأمسكها catch وعرفها بـ instanceof:

true OutOfStockError 3 product 3: wanted 5, 2 left

ولاحظ أننا عيننا this.name في المنشئ، وإلا سيظهر الخطأ في السجلات باسم Error العام. وهذا الكلاس هو ما سوف نستخدمه في خدمة الطلبات لتحويل نقص المخزون إلى رد 409.

الوعد المرفوض دون معالجة Unhandled Rejection

الآن الرسالة الرابعة من القصة. فالدالة save غير متزامنة، أي أنها ترجع وعداً، والخطأ داخلها لا يرمى في مكان الاستدعاء، وإنما يجعل الوعد مرفوضاً. والكود في الحالة rejection استدعاها دون await ودون .catch:

request finished as if all was fine
file:///lab/errors.mjs:12
    const save = async () => { throw new Error('db is down'); };
                                     ^

Error: db is down
    at save (file:///lab/errors.mjs:12:38)
    at Object.rejection (file:///lab/errors.mjs:13:5)
    at file:///lab/errors.mjs:35:15

Node.js v26.13.0
exit code: 1

لاحظ الترتيب: الطلب «انتهى بنجاح» وطبع رسالته أولاً، ثم انهارت العملية كلها برمز 1. والسبب أن Node.js منذ الإصدار 15 يعامل الوعد المرفوض الذي لا يعالجه أحد كاستثناء غير ممسوك، كما في توثيق الخيار --unhandled-rejections حيث الوضع الافتراضي throw. وفي سيرفر حقيقي هذا يعني أن خطأ واحداً في عملية جانبية، مثل حفظ سجل أو إرسال بريد بعد الطلب، يوقف الخدمة ويقطع كل الطلبات الأخرى التي كانت تعمل في نفس العملية. وقبل الإصدار 15 كان Node.js يطبع تحذيراً فقط ويستمر، ولذلك ستجد مشاريع قديمة فيها هذه الأخطاء مخفية لسنوات، ثم تظهر كلها عند الترقية.

لماذا لم يمسك try/catch الخطأ؟ return مقابل return await

والحالة التالية تخدع حتى المطورين المتمرسين. فالدالة فيها try/catch، وتستدعي save() داخل try، ومع ذلك:

Error: db is down
    at save (file:///lab/errors.mjs:17:38)
    at Object.trywithoutawait (file:///lab/errors.mjs:18:18)
    at file:///lab/errors.mjs:35:15

Node.js v26.13.0
exit code: 1

والسبب أن return save() يرجع الوعد نفسه فوراً، والوعد في تلك اللحظة لم يرفض بعد، فتنتهي try بنجاح، وعندما يرفض الوعد بعدها لا يكون هناك catch ينتظره. أما مع return await save() فالدالة تنتظر النتيجة داخل try، فيرمى الخطأ هناك ويمسك:

caught: db is down
exit code: 0

والقاعدة: داخل try استخدم return await دائماً، وخارج try لا فرق في النتيجة. وقد شرحنا كيف تجدول الوعود في حلقة الأحداث في مقال حلقة الأحداث Event Loop، وسوف نعود إلى await بالتفصيل في مقال التزامن من هذه المرحلة.

عدة أخطاء معاً AggregateError

بعض الدوال تجمع عدة وعود، فماذا يحدث إذا فشلت كلها؟ Promise.any ينتظر أول نجاح، وإذا فشلت كل الوعود يرمي AggregateError فيه كل الأخطاء:

[AggregateError: All promises were rejected] {
  [errors]: [
    Error: mirror A
        at Object.any (file:///lab/errors.mjs:25:39)
        at file:///lab/errors.mjs:35:15
    Error: mirror B
        at Object.any (file:///lab/errors.mjs:25:78)
        at file:///lab/errors.mjs:35:15
  ]
}

وإذا كانت مكتبة السجلات عندك تطبع err.message فقط، فسوف ترى «All promises were rejected» دون أي سبب، لذلك تأكد أن السجل يطبع errors وcause أيضاً.

ماذا يقول TypeScript عن متغير catch؟

لأن أي قيمة يمكن أن ترمى، فـ TypeScript في الوضع الصارم يعطي متغير catch النوع unknown وليس Error، بالخيار useUnknownInCatchVariables الذي يفعله strict. وكتبنا الملف catch.ts بنسختين، الأولى تقرأ e.message مباشرة، والثانية تفحص النوع أولاً:

export function readQty(json: string): number {
  try {
    return JSON.parse(json).qty;
  } catch (e) {
    console.error(e.message);
    return 0;
  }
}
export function readQtyFixed(json: string): number {
  try {
    return JSON.parse(json).qty;
  } catch (e) {
    if (e instanceof SyntaxError) console.error('bad JSON:', e.message);
    else throw e;
    return 0;
  }
}

والمخرج من tsc --strict ثم مع إطفاء الخيار:

catch.ts(6,19): error TS18046: 'e' is of type 'unknown'.
tsc exit 1
tsc with useUnknownInCatchVariables=false exit 0

والخطأ في السطر 6 فقط، أي النسخة الأولى، أما الثانية فمرت لأن instanceof ضيق النوع إلى SyntaxError. ولاحظ أيضاً السطر else throw e في النسخة الصحيحة، فهو لا يقل أهمية عن الفحص: امسك ما تعرف كيف تعالجه فقط، وأعد رمي الباقي، لأن catch الذي يبتلع كل شيء ويرجع صفراً يخفي أخطاء لا علاقة لها بالـ JSON. والصورة التالية تجمع حالات الأخطاء كلها:

جدول من ثمانية صفوف: رمي نص بلا Stack Trace، وقراءة خاصية من undefined تعطي TypeError، وError مع cause يطبع السبب، واستدعاء بدون await ينهي العملية بعد طباعة النجاح، وtry مع return بدون await لا يمسك، ومع return await يمسك برمز 0، وPromise.any يعطي AggregateError، وcatch في TypeScript يعطي TS18046، مع رمز الخروج لكل حالة
أغلب الأخطاء التي تهرب من try/catch هي وعود لم ينتظرها أحد

أخطاء شائعة Common Mistakes

  • حساب المال بالريال كرقم عشري: يظهر 29.700000000000003 في الفاتورة، والحل الهللات كأعداد صحيحة من قاعدة البيانات حتى العرض.
  • معرفات خارجية كأرقام: كل معرف أكبر من 2 أس 53 يفقد خاناته بصمت عند JSON.parse، فخزنها نصوصاً.
  • الاعتماد على Number() وparseInt دون فحص: الحقل الفارغ يصبح صفراً، والنص 12abc يصبح 12، فافحص بـ Number.isInteger وحدود القيمة.
  • النسخ بالـ Spread لكائن متداخل: النسخة تشارك الأصل في المصفوفات والكائنات الداخلية، فاستخدم structuredClone أو أنشئ الكائن من جديد.
  • كاش في Map عامة بلا حد: هو تسريب ذاكرة مؤجل، فضع حداً للعدد ومدة صلاحية.
  • --max-old-space-size أكبر من ذاكرة الـ Container: يحول رسالة V8 الواضحة إلى قتل مفاجئ من نظام التشغيل.
  • رمي نصوص بدلاً من Error: تفقد المكدس، ولا يعمل instanceof.
  • استدعاء دالة غير متزامنة دون await أو .catch: الرفض يوقف العملية كلها منذ Node.js 15.
  • return بدون await داخل try: الخطأ يهرب من catch.
  • catch يبتلع كل شيء: امسك النوع الذي تعرفه وأعد رمي الباقي.

جدول قرار Decision Table: أي نوع لأي بيانات؟

البياناتالنوع في JavaScriptالسبب
مبالغ ماليةnumber صحيح بالهللاتدقيق حتى 90 تريليون ريال تقريباً، والكسور عند العرض فقط
كميات ومخزونnumber مع Number.isIntegerالتحويل التلقائي يقبل نصوصاً خاطئة
معرفات من أنظمة أخرىstringلا تحسب عليها، وقد تتجاوز 2 أس 53
أعداد صحيحة كبيرة تحسب عليهاbigintدقيق بأي حجم، ولا يخلط مع number
أعمدة bigint وnumeric من PostgreSQLنص من pg، تحوله أنتالمكتبة لا تخاطر بالدقة
آلاف الأرقام المتشابهةInt32Array أو Float64Array4 أو 8 بايتات للعنصر خارج الـ Heap
كاش لبيانات العملاءMap بحد ومدة صلاحيةالكاش بلا حد تسريب مؤجل
بيانات ملحقة بكائن يملكه غيركWeakMapتختفي مع الكائن تلقائياً

تدريب عملي

  • غير leak.mjs ليكون الحد 1000 مع مدة صلاحية 10 ثوان، وقس heapUsed مرة أخرى، ولاحظ هل يتغير شكل المنشار.
  • شغل leak.mjs leak مع الخيار --heapsnapshot-near-heap-limit=1، وافتح الملف الناتج في أدوات Chrome، وابحث عن الكائن الذي يملك أكبر حجم محتجز Retained Size.
  • أضف إلى heap.mjs مصفوفة فيها أرقام ونص واحد في النهاية، وقارن تكلفة العنصر بالمصفوفة الرقمية الخالصة.
  • اكتب دالة parseQty ترجع رقماً صحيحاً موجباً أو ترمي RangeError مع cause، واختبرها على '' و'12abc' و'3' و'-1'.

الخلاصة

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

  • في JavaScript القيمة هي التي تحمل النوع، وسبعة أنواع بسيطة تنسخ بقيمتها، وكل ما عداها كائنات تنسخ بمرجعها، والـ Spread وObject.freeze كلاهما سطحي.
  • التحويل التلقائي جعل الحقل الفارغ صفراً و12abc رقماً 12، فحول القيم الخارجية بنفسك عند الحدود واستخدم ===.
  • الرقم الواحد بدقة 64 بت أعطى 29.700000000000003 لثلاث علب هيل، وأفقد رقم الشحنة خانته الأخيرة بعد 2 أس 53، فالمال بالهللات والمعرفات نصوص.
  • الكائن الصغير كلف 56 بايتاً مقابل 8 للرقم في المصفوفة، وحد الـ Heap كان 268 ميجابايت في Container بذاكرة 512، ونصف الذاكرة تقريباً في كل الأحجام.
  • الكاش بلا حد انهار بعد 30 ألف طلب برمز 134، ونفس الكاش بحد 1000 بقي بين 6.7 و20.8 ميجابايت بعد 200 ألف طلب.
  • ارم Error مع cause، وانتظر كل وعد أو عالجه، واستخدم return await داخل try، واعتبر متغير catch من نوع unknown حتى تفحصه.

والمقالات التالية في مرحلة JavaScript وTypeScript تبني على هذا: التزامن بالوعود والعمال، ثم خدمة الطلبات الحقيقية التي تستخدم الهللات وOutOfStockError وحد الذاكرة الذي قسناه هنا:

دليلJavaScript لمن يعرف لغة أخرى: Node.js مع TypeScript والأدواتإذا كنت تبرمج بلغة أخرى وانتقلت إلى فريق يعمل بـ Node.js وTypeScript، فهذا يومك الأول: الإصدار الصحيح، والصيغة مقارنة بما تعرفه، وأدوات العمل وهيكل المشروع بأوامر ومخرجات حقيقية.دليلTypeScript للمطورين: أي الأخطاء تكشفها الأنواع Types؟زرعنا تسعة أخطاء في كود سلة مشتريات وفحصناها بالمترجم tsc بثلاثة إعدادات، لنرى ما يكشفه الوضع الصارم وما يمر رغم كل الأنواع ولماذا تحتاج التحقق وقت التشغيل.دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.

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

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

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

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

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

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