الوحدة 2 · الـ Generics بعمق
كيف يعمل الـ Inference، ومعاملات النوع const
20 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
توقّع ما يستنتجه TypeScript لمعامل النوع، واحتفظ بالأنواع الحرفية عبر القيود ومعاملات النوع const، ورتّب وسائط الـ callbacks كي يسري الـ inference في الاتجاه الصحيح.
- 1مواقع الاستنتاج والمرشّحون
- 2متى تتّسع القيم الحرفية
- 3معاملات النوع const
- 4الـ callbacks تُستنتج أخيرًا
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
في الدرس السابق كتبت sortByTotal(orders) فاستنتج TypeScript أن T هو DetailedOrder. في معظم الأحيان يفعل الـ inference (استنتاج النوع) ما تريده، ولهذا تبدو المرات التي لا يفعل فيها عشوائية. لكنها ليست كذلك. يتبع TypeScript مجموعة صغيرة من القواعد، وحين تعرفها تستطيع تصميم توقيعات تستنتج بالضبط النوع الذي تقصده، دون أي <…> في موضع الاستدعاء.
مواقع الاستنتاج والمرشّحون
كل موضع يظهر فيه معامل النوع داخل نوع معامل هو موقع استنتاج (inference site). عند الاستدعاء، يطابق TypeScript كل وسيط مع نوع معامله، ويجمع مرشّحًا (candidate) لمعامل النوع من كل موقع. ثم يختار نوعًا واحدًا من بين المرشّحين.
function same<T>(a: T, b: T): T[] {
return [a, b];
}
same('processing', 'shipped'); // T = string, so string[]
same(1, 'a');
// Error: Argument of type 'string' is not assignable to parameter of type 'number'.مع قيمتين نصيتين حرفيتين، يُدمج المرشّحان 'processing' و'shipped' ثم يتّسعان إلى string، لأن T لا يُعاد إلا داخل مصفوفة (يشرح القسم التالي متى تنجو القيم الحرفية). أما مع 1 و'a'، فلا يخترع TypeScript النوع number | string؛ بل يختار المرشّح الأول، number، فيفشل الوسيط الثاني أمامه. وهذا مقصود. فالاتساع الصامت إلى union سيُخفي الأخطاء التي وُجدت الـ generics لالتقاطها. إن أردت قيمًا مختلطة فعلًا، فقلها صراحة: same<number | string>(1, 'a').
متى تتّسع القيم الحرفية
القاعدة الثانية تفسّر معظم لحظات “لماذا حصلت على string؟”. المرشّح الحرفي مثل 'processing' يُحتفظ به أو يُوسَّع حسب التوقيع:
function identity<T>(value: T): T { return value; }
function box<T>(value: T): { value: T } { return { value }; }
function boxStatus<T extends string>(value: T): { value: T } { return { value }; }
const a = identity('processing'); // 'processing'
const b = box('processing'); // { value: string }
const c = boxStatus('processing'); // { value: 'processing' }تنجو القيمة الحرفية حين يُعاد T مباشرة في المستوى الأعلى (identity)، أو حين يتضمّن قيد T نوعًا أوليًا مثل string (boxStatus). وإلا فإنها تتّسع، بناءً على افتراض أن القيمة المخبّأة داخل كائن سيُعاد الإسناد إليها غالبًا لاحقًا.
الكائنات تتبع المنطق نفسه، وهنا للقيد فائدة ثانية: يعطي الوسيطَ نوعًا سياقيًا (contextual type).
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
function draft<T>(order: T): T { return order; }
function draftOrder<T extends { status: OrderStatus }>(order: T): T { return order; }
draft({ status: 'processing' }); // { status: string }
draftOrder({ status: 'processing' }); // { status: 'processing' }لأن القيد يقول إن status من نوع OrderStatus، يفحص TypeScript القيمة الحرفية مقابل ذلك الـ union أثناء الاستنتاج، فيثبت النوع الحرفي. القيد الجيد يوثّق الدالة ويحسّن ما تستنتجه في آنٍ واحد.
معاملات النوع const
أحيانًا تريد وسيط المستدعي كما كُتب بالضبط: ترتيب الـ tuple، والقيم الحرفية، والكائنات المتداخلة. قبل TypeScript 5.0 كان ذلك يعني أن تطلب من كل مستدعٍ أن يكتب as const. أما الآن فيمكن للدالة أن تطلب ذلك بنفسها:
function defineFlow<const T extends readonly OrderStatus[]>(steps: T): T {
return steps;
}
const happyPath = defineFlow(['processing', 'shipped', 'delivered']);
// readonly ['processing', 'shipped', 'delivered']المعدِّل const يطلب من TypeScript أن يستنتج الوسيط كأنه مكتوب مع as const: القيم الحرفية تبقى حرفية، والمصفوفات تصبح tuples للقراءة فقط، وخصائص الكائنات تصبح للقراءة فقط. اقرنه بقيد مصفوفة readonly، لأن as const تنتج مصفوفات للقراءة فقط. ولا يؤثّر إلا في القيم الحرفية المكتوبة مباشرة في الاستدعاء؛ أما المتغيّر الذي تمرّره فيحتفظ بأي نوع كان له أصلًا.
الـ callbacks تُستنتج أخيرًا
الوسائط التي هي دوال بمعاملات غير معلَّقة، مثل (d) => d.totalCents، حسّاسة للسياق (context-sensitive): أنواع معاملاتها تأتي من التوقيع، الذي قد يعتمد بدوره على T. يستنتج TypeScript من كل ما عداها أولًا، ثم يحدّد أنواع هذه الـ callbacks. وداخل الكائن الحرفي الواحد، يمرّ على الخصائص الحسّاسة للسياق من اليسار إلى اليمين:
function createStep<T>(config: { load: (orderId: number) => T; render: (data: T) => string }) {}
createStep({
load: (orderId) => ({ orderId, totalCents: 4500 }),
render: (d) => d.totalCents.toFixed(), // d: { orderId: number; totalCents: number }
});
createStep({
render: (d) => d.totalCents.toFixed(), // Error: 'd' is of type 'unknown'.
load: (orderId) => ({ orderId, totalCents: 4500 }),
});حين تأتي render أولًا، لا يكون لـ T أي مرشّح بعد. يمكنك الإصلاح في موضع الاستدعاء بتبديل الخاصيتين أو بتعليق orderId: number، فلا تعود load حسّاسة للسياق. والأفضل أن تُصلحه في الـ API نفسه: ضع المنتِج قبل المستهلِك في قوائم المعاملات وفي الأمثلة الموثّقة، كي تكون الطريقة الطبيعية لاستدعائها هي الطريقة التي تستنتج.
في التمرين ستجعل ثلاث دوال مساعدة في Cartwheel تحتفظ بالقيم الحرفية التي يمرّرها مستدعوها. وبعده ستتعلّم كيف تمنع الـ inference من الحدوث حيث لا ينبغي.
أهم النقاط
- كل موضع يظهر فيه معامل النوع داخل نوع معامل هو موقع استنتاج؛ يجمع TypeScript مرشّحًا من كل وسيط ثم يختار نوعًا واحدًا.
- تتّسع القيم الحرفية المستنتَجة ما لم يتضمّن قيد معامل النوع نوعًا أوليًا أو يُعرَّف المعامل بـ
const. - معامل النوع
constيستنتج الوسائط كأنها مكتوبة معas const، فيحتفظ بالقيم الحرفية والـ tuples للقراءة فقط دون أن يطلب ذلك من المستدعي. - الـ callbacks ذات المعاملات غير المعلَّقة تُستنتج بعد الوسائط الأخرى، وداخل الكائن الحرفي الواحد يعمل TypeScript من اليسار إلى اليمين.
- وسائط النوع الصريحة إمّا كلها أو لا شيء؛ حدّد واحدًا فيتوقّف TypeScript عن استنتاج البقية.