الوحدة 3 · البرمجة على مستوى الأنواع
الأنواع التعاودية ومتى تتوقّف
20 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
اكتب أنواعًا تعاودية للأشجار وJSON والتحويلات العميقة، وافهم حدود العمق في الـ compiler، وقرّر متى يكلّف النوع الذكي أكثر من الخطأ الذي يمنعه.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
فئات المنتجات في Cartwheel تشكّل شجرة: Kitchen تحتوي Cookware، التي تحتوي Pans. يُعيد الـ API هذه الشجرة كـ JSON متداخل، وتعدّل شاشة إعدادات الدفع كائن إعداد متداخلًا بعمق. كلاهما يحتاج إلى أنواع تشير إلى نفسها. يتعامل TypeScript مع ذلك جيدًا، إلى حدٍّ ما. هذا الدرس يغطّي كيف تكتب الأنواع التعاودية، وأين تقع الحدود، والقرار التقديري الذي كان هذا القسم كله يمهّد له: متى تتوقّف.
أنواع تشير إلى نفسها
يمكن للاسم المستعار للنوع أن يذكر نفسه داخل نوع كائن أو مصفوفة:
type Category = { id: number; name: string; children: Category[] };
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };Category يصف شجرة بأي عمق. وJson يصف أي قيمة يمكن أن تُعيدها JSON.parse، ما يجعله نوع إرجاع أفضل بكثير لمحلّل من any: إنه صادق، ويجبرك على التضييق قبل الاستخدام.
الدوال التعاودية على مستوى الأنواع تتبع الفكرة نفسها. Awaited<T>، المدمج في الـ lib منذ TypeScript 4.5، يفكّ الـ promises حتى لا يبقى منها شيء. إليك نسخة مبسّطة:
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
type Loaded = MyAwaited<Promise<Promise<Order>>>; // Orderكل خطوة تقشر Promise واحدًا وتستدعي نفسها على ما كان بداخله. وحين لا يعود النوع promise، يُعيده الفرع الكاذب وينتهي التعاود.
التحويلات العميقة
الـ mapped types من الـ Mapped Types وإعادة تسمية المفاتيح سطحية. والتعاود يجعلها عميقة:
type DeepReadonly<T> = T extends (...args: any[]) => any
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type FrozenCheckout = DeepReadonly<Checkout>;لكل حالة سببها. الأنواع الأولية تمرّ دون تغيير. والكائنات تُحوَّل، وتُعالَج كل خاصية تعاوديًا. والدوال لها فرعها الخاص لأن الـ mapped type يحتفظ بالخصائص لا بتوقيعات الاستدعاء: دون هذا الفرع، سيتحوّل handler مثل onSubmit إلى كائن لا يمكنك استدعاؤه. أما المصفوفات فلا تحتاج حالة خاصة هنا، لأن الـ mapped type التماثلي المطبَّق على مصفوفة ينتج مصفوفة للقراءة فقط.
DeepPartial هو الآخر الذي ستصادفه، عادةً لبيانات الاختبار الجاهزة (fixtures) ولتجاوزات الإعداد:
type DeepPartial<T> = T extends readonly unknown[]
? T
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
declare function checkoutFixture(overrides?: DeepPartial<Checkout>): Checkout;
checkoutFixture({ shipping: { address: { country: 'JO' } } });هنا تُحفظ المصفوفات كاملة عن قصد: مصفوفة جزئية من بنود جزئية نادرًا ما تكون ما يقصده الاختبار.
المسارات، وكلفة الذكاء
هذا هو النوع الذي يلجأ إليه الناس حين يريدون أن تُفحص get(checkout, 'shipping.address.city'):
type Paths<T> = {
[K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
}[keyof T & string];
type CheckoutPath = Paths<{ shipping: { address: { city: string; country: string } }; totalCents: number }>;
// 'shipping' | 'shipping.address' | 'shipping.address.city' | 'shipping.address.country' | 'totalCents'إنه يعمل، وعلى نوع صغير يكون ممتعًا. لكنه أيضًا mapped type وشرط وtemplate literal وتعاود في أربعة أسطر، وكلفته تنمو مع كل حقل وكل مستوى. على كائن حالة حقيقي من 40 حقلًا فيه مصفوفات وتواريخ، قد ينتج نوع كهذا آلاف الأعضاء ويجعل كل ضغطة مفتاح في المحرّر أبطأ.
ولدى TypeScript أيضًا حدود صارمة. كل تجسيد متداخل يُحسب، وبعد بضع عشرات من مستويات التعاود العادي تحصل على Type instantiation is excessively deep and possibly infinite. ومنذ TypeScript 4.5، يُحسَّن الـ conditional type الذي يكون استدعاؤه التعاودي في موضع ذيلي (tail position) (أي النتيجة الكاملة لفرع، كما في MyAwaited)، ويمكن أن يُنفَّذ نحو 1,000 تكرار. ولا يوجد خيار في الـ compiler لرفع أيٍّ من الحدّين.
الأنواع التعاودية هي الأداة الصحيحة للبيانات التعاودية فعلًا: الأشجار، وJSON، والإعدادات المتداخلة. ولكل ما عدا ذلك، اكتب النوع الممل أولًا، ولا تلجأ إلى الذكي إلا حين يبرّره خطأ حقيقي.
في التمرين ستكتب MyAwaited وDeepReadonly وDeepPartial، وتستخدم الأخير لبيانات اختبار جاهزة. وبه يُختم قسم مستوى الأنواع. القسم 4 ينتقل إلى الأنماط: ما تبنيه الفرق المتمرّسة فعلًا بكل هذا.
أهم النقاط
- يمكن للاسم المستعار للنوع (type alias) أن يشير إلى نفسه، وهكذا تصف الأشجار وJSON وغيرها من البيانات المتداخلة.
- الدوال التعاودية على مستوى الأنواع مثل
DeepReadonlyوDeepPartialتحتاج إلى حالات صريحة للدوال والمصفوفات، وإلا حوّلت أشياء لم تقصد لمسها. - يوقف TypeScript التعاود بعد بضع عشرات من المستويات المتداخلة، أو نحو 1,000 للـ conditional types ذات التعاود الذيلي، برسالة “Type instantiation is excessively deep”.
readonlyتمنع فقط إسناد الخصائص؛ أما methods مثلDate#setFullYearأوMap#setفما زالت تعدّل القيمة المعرَّفة للقراءة فقط بعمق.- قبل اعتماد نوع ذكي، اسأل ما الخطأ الذي يمنعه، وهل يستطيع زميل قراءة أخطائه، وكم يكلّف المحرّر.