> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# الأنواع Types والذاكرة والأخطاء في JavaScript
- URL: https://arabroot.io/articles/الانواع-والذاكرة-والاخطاء-في-javascript/
- Published: 2026-10-31T07:14:11.000Z
- Updated: 2026-10-31T08:10:06.000Z
- Description: لماذا ظهر في الفاتورة 29.700000000000003، ولماذا انهارت الخدمة برسالة heap out of memory، وأين يهرب الخطأ من try/catch: قيم JavaScript وذاكرتها وأخطاؤها بقياسات وتشغيلات حقيقية على Node.js 26.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

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

والإجابة أن الرسائل الثلاث تأتي من ثلاثة أشياء تختلف فيها JavaScript عن أغلب اللغات التي تعرفها: كيف تحمل القيمة نوعها وتتحول إلى نوع آخر دون أن تطلب، وكيف يدير المحرك V8 الذاكرة ومتى يستسلم، وكيف ينتقل الخطأ بين الكود المتزامن والوعود. وهذا المقال من مرحلة [JavaScript وTypeScript](https://arabroot.io/start/#stage-lang-js)، ويبني على مقال [TypeScript للمطورين](https://arabroot.io/articles/typescript-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D9%85%D8%A7-%D8%A7%D9%84%D8%B0%D9%8A-%D8%AA%D9%83%D8%B4%D9%81%D9%87-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9/) الذي قاس ما تكشفه الأنواع قبل التشغيل، فلن نكرر تجربته، وإنما نذهب إلى ما تحت الأنواع: القيم نفسها وقت التشغيل، وتكلفتها في الذاكرة بالبايت، والأخطاء كما يطبعها 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، أي أن المتغير مجرد اسم يشير إلى قيمة، والقيمة هي التي تحمل نوعها وقت التشغيل، فيمكن أن يشير نفس المتغير إلى رقم ثم إلى نص ثم إلى كائن. وقد شرحنا الفرق بين الأنظمة الثابتة والديناميكية والقوية والضعيفة في مقال [أنماط البرمجة وأنظمة الأنواع](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، وJavaScript فيها ديناميكية مع تحويل تلقائي كثير، وهذا المزيج هو مصدر أغلب المفاجآت.

وبحسب [معيار ECMAScript](https://tc39.es/ecma262/?ref=arabroot.io) وشرح MDN لـ [هياكل البيانات في JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data%5Fstructures?ref=arabroot.io)، توجد سبعة أنواع بسيطة Primitives وهي `number` و`bigint` و`string` و`boolean` و`undefined` و`null` و`symbol`، وكل ما عدا ذلك كائن Object، بما في ذلك المصفوفات والدوال والتواريخ و`Map`. والقيم البسيطة غير قابلة للتغيير Immutable، فلا يوجد شيء اسمه تعديل حرف داخل نص، وإنما تنشئ نصاً جديداً، أما الكائنات فقابلة للتغيير، وتنتقل بالمرجع Reference. وشغلنا الملف `values.mjs` الذي يطبع نتيجة `typeof` لكل نوع، والمخرج سوف يكون كما يلي:

```text
--- 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof?ref=arabroot.io) تذكر ذلك صراحة. ولذلك لا تفحص الكائن بـ `typeof x === 'object'` وحده، لأن `null` سيمر.
- المصفوفة `[]` أيضاً `object`، والفحص الصحيح لها `Array.isArray`.
- الدالة لها نوع خاص `function`، مع أنها كائن من الداخل يمكن أن تضيف إليه خصائص.
- `42n` نوع مستقل `bigint`، وسوف نعود إليه مع الأرقام الكبيرة.

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

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

```javascript
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);
```

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

```text
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](https://arabroot.io/content/images/2026/10/js-types-memory-01-values.webp)

القيم البسيطة تنسخ بقيمتها، والكائنات بمرجعها، والـ Spread ينسخ مستوى واحداً

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

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

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

```text
--- 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 هذه الخطوات في صفحة [المقارنة والتساوي](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Equality%5Fcomparisons%5Fand%5Fsameness?ref=arabroot.io).

![جدولان: العمليات مثل 1 زائد النص 2 تعطي النص 12 والضرب والطرح يحولان إلى رقم وNumber للنص الفارغ تعطي 0 وللنص 12abc تعطي NaN بينما parseInt تعطي 12، والمقارنات مثل المصفوفة الفارغة تساوي false ونص الصفر يساوي false وNaN لا تساوي نفسها وObject.is يعطي true، وتحتها قاعدة التحقق عند الحدود](https://arabroot.io/content/images/2026/10/js-types-memory-02-coercion.webp)

كل سطر في الجدول خطأ محتمل في خدمة تستقبل بيانات من الخارج

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

والقاعدة العملية: لا تترك المحرك يحول القيم القادمة من الخارج، وإنما حولها بنفسك مرة واحدة عند الحدود بدالة صارمة مثل `Number()` مع فحص `Number.isInteger`، أو بمكتبة تحقق Validation، ثم استخدم `===` في كل الكود، ولا تقارن بـ `NaN` أبداً لأنها لا تساوي حتى نفسها، واستخدم `Number.isNaN`. وأداة التدقيق Biome التي ثبتناها في مقال [JavaScript لمن يعرف لغة أخرى](https://arabroot.io/articles/javascript-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D9%84%D8%BA%D8%A9-%D8%A7%D8%AE%D8%B1%D9%89-node-js-%D9%85%D8%B9-typescript/) تمنع `==` تلقائياً بالقاعدة `noDoubleEquals`، و TypeScript يرفض أغلب العمليات بين أنواع مختلفة قبل التشغيل، ولكن كلاهما لا يرى البيانات التي تصل وقت التشغيل.

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

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

```text
--- 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 من الداخل](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/)، والحل في خدمة الطلبات هو ما فعلناه في كل المسار: المبالغ أعداد صحيحة بالهللات، فـ 990 في 3 تساوي 2970 بالضبط، والكسور لا تظهر إلا عند العرض.

والثاني هو الأعداد الصحيحة الكبيرة: لأن الرقم عشري، فالأعداد الصحيحة دقيقة حتى [Number.MAX\_SAFE\_INTEGER](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Number/MAX%5FSAFE%5FINTEGER?ref=arabroot.io)، أي 2 أس 53 ناقص 1، وبعدها تبدأ الفجوات، فـ `MAX_SAFE_INTEGER + 2` أعطى رقماً زوجياً خاطئاً بدون أي خطأ. وأخطر سطر في المخرج هو `JSON.parse`، فرقم الشحنة `9007199254740993` في رد JSON من الشريك تحول إلى `9007199254740992`، وهذه هي الرسالة الثانية في القصة: الخدمة قرأت الرقم، وضاعت منه الخانة الأخيرة، فسألت عن شحنة أخرى. والقاعدة أن كل معرف يأتي من نظام آخر يخزن نصاً وليس رقماً، حتى لو كان كله أرقاماً، فأنت لا تجمع أرقام الشحنات ولا تضربها.

وعندما تحتاج حساباً حقيقياً على أعداد صحيحة كبيرة، فاللغة فيها النوع [BigInt](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/BigInt?ref=arabroot.io) منذ ES2020، تكتبه بحرف `n` في نهاية الرقم، وهو دقيق بأي حجم. ولكن لاحظ السطر الأخير: `1n + 1` رمى `TypeError`، لأن اللغة ترفض خلط `bigint` مع `number` تلقائياً، وهذا التحويل الوحيد تقريباً الذي ترفضه JavaScript، والسبب أن أحدهما سيفقد دقته حتماً. والصورة التالية تجمع الحالات الثلاث:

![ثلاثة صناديق: الكسور حيث 0.1 زائد 0.2 و9.90 في 3 تعطي قيماً بخانات زائدة، والأعداد الكبيرة حيث MAX_SAFE_INTEGER زائد 2 ورقم JSON يفقدان الخانة الأخيرة، والحل بالهللات وBigInt مع خطأ خلط الأنواع، وتحتها خط أعداد يبين منطقة الدقة حتى 2 أس 53 ثم الفجوات](https://arabroot.io/content/images/2026/10/js-types-memory-03-numbers.webp)

نوع رقمي واحد: دقيق للأعداد الصحيحة حتى 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 ويقرؤه مرة أخرى:

```javascript
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);
```

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

```text
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](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/). والقاعدة التي يعمل بها الجامع هي الوصول Reachability: كل قيمة يمكن الوصول إليها من الجذور، أي المتغيرات العامة ومكدس الاستدعاءات الحالي وما يشير إليه كل منهما، تبقى حية، وكل ما لا يمكن الوصول إليه يجمع. وهذا يعني أن «التسريب» في JavaScript ليس ذاكرة نسيت أن تحررها، وإنما مرجع نسيت أن تحذفه، فالجامع لا يستطيع أن يجمع شيئاً ما زال كاش عام يشير إليه.

والمحرك V8 يقسم الـ Heap إلى جيلين، والفكرة مبنية على ملاحظة تجريبية اسمها الفرضية الجيلية Generational Hypothesis: أغلب الكائنات تموت صغيرة. فالكائن المؤقت الذي تنشئه لمعالجة طلب واحد ينتهي دوره بعد ميلي ثوان، ولذلك يشرح فريق V8 في [تدوينة جامع القمامة Orinoco](https://v8.dev/blog/trash-talk?ref=arabroot.io) أن الجامع يتعامل مع الجيلين بطريقتين:

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

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

```text
[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](https://nodejs.org/docs/latest-v26.x/api/v8.html?ref=arabroot.io):

```bash
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 ميجابايت:

```text
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](https://arabroot.io/content/images/2026/10/js-types-memory-05-gc.webp)

Node.js يقرأ حد ذاكرة الـ Container ويحسب حد الـ Heap منه

وهذه الأرقام تقول إن Node.js 26 يقرأ حد الذاكرة المفروض على الـ Container ويضع حد الـ Heap عند نصفه تقريباً، فـ 2 جيجابايت أعطت 1072 ميجابايت، و1 جيجابايت أعطت 536، أما بدون حد فأخذ الحد الافتراضي 4192 ميجابايت مهما كانت ذاكرة الجهاز. والنصف الآخر ليس ضائعاً، فالعملية تحتاج ذاكرة خارج الـ Heap للـ Buffers والمكتبات الأصلية ومكدسات الخيوط والكود المترجم. ومع 256 ميجابايت لاحظ أن الحد صار 262، وهو أكثر من النصف، فالمحرك لا ينزل تحت حد أدنى معين. والخيار [\--max-old-space-size](https://nodejs.org/docs/latest-v26.x/api/cli.html?ref=arabroot.io) يضبط حجم الجيل القديم وحده بالميجابايت، ولذلك صار الحد الكلي 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()` متاحة:

```javascript
// 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));
```

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

```text
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](https://arabroot.io/content/images/2026/10/js-types-memory-04-heap-cost.webp)

الكائن الصغير يكلف سبعة أضعاف الرقم في المصفوفة

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

- **الأعداد الصحيحة الصغيرة 8 بايتات**: V8 يخزن العدد الصحيح الصغير داخل الخانة نفسها مباشرة، ويسميه Smi اختصاراً لـ Small Integer، فلا يوجد كائن لكل رقم. والخانة في بناء Node.js الرسمي 8 بايتات، أما Chrome فيستخدم [ضغط المؤشرات Pointer Compression](https://v8.dev/blog/pointer-compression?ref=arabroot.io) فتصبح 4.
- **الأعداد العشرية أيضاً 8 بايتات**: لأن V8 يلاحظ أن المصفوفة كلها أرقام عشرية فيخزنها كمصفوفة `double` متصلة دون كائن لكل رقم، وهذا ما يسميه فريق V8 أنواع العناصر [Elements Kinds](https://v8.dev/blog/elements-kinds?ref=arabroot.io). ولو خلطت في نفس المصفوفة رقماً ونصاً لانتقلت إلى النوع العام الأبطأ والأكبر.
- **الكائن الصغير 56 بايتاً**: ثلاثة حقول قيمتها 24 بايتاً فقط، والباقي رأس الكائن: مؤشر إلى شكله المخفي Hidden Class الذي يصف أسماء الحقول ومواقعها، ومؤشر للخصائص الإضافية، ومؤشر للعناصر، ثم الخانة في المصفوفة التي تشير إلى الكائن. فمليون طلب في الذاكرة كمصفوفة كائنات يكلف سبعة أضعاف نفس الأرقام في مصفوفة أرقام.
- **النص القصير 40 بايتاً تقريباً**: رأس النص مع طوله وهاشه، ثم الحروف مقربة إلى حجم محاذاة، ثم الخانة في المصفوفة.
- **الـ `Map` 28 بايتاً لكل زوج**: المفتاح والقيمة ومؤشر السلسلة داخل جدول الهاش، مع مساحة احتياطية لأن الجدول يكبر بالمضاعفة.

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

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

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

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

```text
Int32Array(1M)                              0.0 MB    0.0 bytes/item
```

ومصفوفة بمليون عدد من 4 بايتات لا يمكن أن تكون مجانية، والسبب أن بيانات المصفوفات المكتوبة Typed Arrays ومعها `Buffer` في Node.js تحجز خارج الـ Heap الذي يديره الجامع، ولا يظهر في `heapUsed` إلا كائن صغير يشير إليها. والقيمة الصحيحة تظهر في الحقل `arrayBuffers` من [process.memoryUsage()](https://nodejs.org/docs/latest-v26.x/api/process.html?ref=arabroot.io)، فعدلنا الدالة لتجمع الحقلين، فظهر الرقم الصحيح 4 بايتات بالضبط. وهذه ملاحظة مهمة للمراقبة أيضاً: خدمة ترفع ملفات أو تعالج صوراً قد يكون أغلب ذاكرتها خارج `heapUsed`، فراقب `rss` و`external` و`arrayBuffers` معه، وإلا ستبحث عن تسريب في المكان الخطأ.

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

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

```javascript
// 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');
```

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

```text
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`. والمخرج بعد مئتي ألف طلب، أي أكثر من ستة أضعاف ما قتل النسخة الأولى:

```text
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 حتى النهاية](https://arabroot.io/content/images/2026/10/js-types-memory-06-leak.webp)

التسريب خط يصعد ولا ينزل، والكاش المحدود منشار يصعد وينزل مع كل جمع

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

وقد يتساءل البعض: أليس `WeakMap` هو الحل الجاهز لهذا؟ والإجابة أنه حل لمشكلة أخرى. فـ [WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/WeakMap?ref=arabroot.io) لا تمنع الجامع من جمع المفتاح، فإذا لم يبق أي مرجع للكائن المفتاح في مكان آخر اختفى الزوج من الخريطة تلقائياً، وهذا مفيد عندما تريد إلحاق بيانات إضافية بكائن يملكه غيرك، مثل كائن الطلب الحالي. ولكن مفاتيحها يجب أن تكون كائنات وليس نصوصاً، فلا يمكن أن تكون `'user-42'` مفتاحاً فيها، ولا يمكنك أن تعرف حجمها أو تمر على عناصرها، فهي ليست كاشاً.

وعندما يحدث تسريب حقيقي في الإنتاج فلن تعرف مكانه من رسالة الانهيار، والأداة الصحيحة هي لقطة الـ Heap Snapshot، تأخذ اثنتين بفارق زمني وتقارن أي نوع من الكائنات زاد عدده، كما يشرح دليل [استخدام Heap Snapshot](https://nodejs.org/en/learn/diagnostics/memory/using-heap-snapshot?ref=arabroot.io) في توثيق Node.js، ويمكن أن يأخذها Node.js تلقائياً قبل الانهيار بالخيار `--heapsnapshot-near-heap-limit`. وقد شرحنا أدوات التصحيح عموماً في مقال [قراءة رسالة الخطأ والتصحيح Debugging](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-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، فلا تعرف من توقيع الدالة ما الذي قد ترميه، وجزء كبير من الأخطاء لا يرمى في نفس المكدس أصلاً، وإنما ينتقل داخل وعد مرفوض. وقد قارنا طرق اللغات في التعامل مع الأخطاء في مقال [كيف تتعامل لغات البرمجة مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)، وهنا سوف نرى الحالات كما يطبعها Node.js 26 فعلاً من الملف `errors.mjs`:

```javascript
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 وليس نصاً

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

```text
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`:

```text
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Error/cause?ref=arabroot.io)، وNode.js يطبع السلسلة كاملة:

```text
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`:

```text
true OutOfStockError 3 product 3: wanted 5, 2 left
```

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

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

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

```text
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](https://nodejs.org/docs/latest-v26.x/api/cli.html?ref=arabroot.io) حيث الوضع الافتراضي `throw`. وفي سيرفر حقيقي هذا يعني أن خطأ واحداً في عملية جانبية، مثل حفظ سجل أو إرسال بريد بعد الطلب، يوقف الخدمة ويقطع كل الطلبات الأخرى التي كانت تعمل في نفس العملية. وقبل الإصدار 15 كان Node.js يطبع تحذيراً فقط ويستمر، ولذلك ستجد مشاريع قديمة فيها هذه الأخطاء مخفية لسنوات، ثم تظهر كلها عند الترقية.

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

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

```text
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`، فيرمى الخطأ هناك ويمسك:

```text
caught: db is down
exit code: 0
```

والقاعدة: داخل `try` استخدم `return await` دائماً، وخارج `try` لا فرق في النتيجة. وقد شرحنا كيف تجدول الوعود في حلقة الأحداث في مقال [حلقة الأحداث Event Loop](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/)، وسوف نعود إلى `await` بالتفصيل في مقال التزامن من هذه المرحلة.

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

بعض الدوال تجمع عدة وعود، فماذا يحدث إذا فشلت كلها؟ `Promise.any` ينتظر أول نجاح، وإذا فشلت كل الوعود يرمي [AggregateError](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/AggregateError?ref=arabroot.io) فيه كل الأخطاء:

```text
[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` مباشرة، والثانية تفحص النوع أولاً:

```typescript
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` ثم مع إطفاء الخيار:

```text
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، مع رمز الخروج لكل حالة](https://arabroot.io/content/images/2026/10/js-types-memory-07-errors.webp)

أغلب الأخطاء التي تهرب من 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 أو Float64Array | 4 أو 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](https://arabroot.io/start/#stage-lang-js) تبني على هذا: التزامن بالوعود والعمال، ثم خدمة الطلبات الحقيقية التي تستخدم الهللات و`OutOfStockError` وحد الذاكرة الذي قسناه هنا:

[![](https://arabroot.io/content/images/2026/10/js-for-devs-cover-cover.webp)دليلJavaScript لمن يعرف لغة أخرى: Node.js مع TypeScript والأدواتإذا كنت تبرمج بلغة أخرى وانتقلت إلى فريق يعمل بـ Node.js وTypeScript، فهذا يومك الأول: الإصدار الصحيح، والصيغة مقارنة بما تعرفه، وأدوات العمل وهيكل المشروع بأوامر ومخرجات حقيقية.](https://arabroot.io/articles/javascript-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D9%84%D8%BA%D8%A9-%D8%A7%D8%AE%D8%B1%D9%89-node-js-%D9%85%D8%B9-typescript/)[![](https://arabroot.io/content/images/2026/10/typescript-for-devs-cover-cover.webp)دليلTypeScript للمطورين: أي الأخطاء تكشفها الأنواع Types؟زرعنا تسعة أخطاء في كود سلة مشتريات وفحصناها بالمترجم tsc بثلاثة إعدادات، لنرى ما يكشفه الوضع الصارم وما يمر رغم كل الأنواع ولماذا تحتاج التحقق وقت التشغيل.](https://arabroot.io/articles/typescript-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D9%85%D8%A7-%D8%A7%D9%84%D8%B0%D9%8A-%D8%AA%D9%83%D8%B4%D9%81%D9%87-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9/)[![](https://arabroot.io/content/images/2026/10/memory-management-cover-cover.webp)دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)

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

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