الوحدة 2 · الـ Generics بعمق
الـ Generics بوصفها علاقات
18 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
اكتب دوالًا generic تربط معاملاتُ النوع فيها المدخلات بالمخرجات، وقيّدها بـ extends، وتعرّف على الـ generics التي هي في الحقيقة تحويلات غير مفحوصة.
- 1معامل النوع علاقة
- 2قاعدة الظهور مرتين
- 3المستدعي هو من يختار T
- 4قيود تستخدم معاملات أخرى
- 5generic أم union بسيط؟
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
إليك دالة مساعدة من صفحة سجل الطلبات في Cartwheel. أنواعها محدّدة، وتمرّ في الترجمة، وترمي المعلومات بهدوء:
type DetailedOrder = { id: number; totalCents: number; customer: string };
function sortByTotal(orders: { totalCents: number }[]): { totalCents: number }[] {
return [...orders].sort((a, b) => a.totalCents - b.totalCents);
}
declare const orders: DetailedOrder[];
const sorted = sortByTotal(orders);
sorted[0].customer;
// Error: Property 'customer' does not exist on type '{ totalCents: number; }'.الكائنات في sorted ما زالت طلبات كاملة وقت التشغيل. لكن توقيع الدالة لا يستطيع وصف مخرجها إلا من خلال التعليق النوعي على مدخلها، وهذا التعليق لا يذكر إلا totalCents. والإصلاح ليس تعليقًا أوسع، بل علاقة: “أيًّا كان نوع الكائن الذي تعطيني إياه، ستحصل على النوع نفسه”.
معامل النوع علاقة
هذه العلاقة هي ما يقوله الـ generic:
function sortByTotal<T extends { totalCents: number }>(orders: T[]): T[] {
return [...orders].sort((a, b) => a.totalCents - b.totalCents);
}
const sorted = sortByTotal(orders); // DetailedOrder[]
sorted[0].customer; // stringاقرأ التوقيع بصوت عالٍ: “لنوعٍ ما T فيه totalCents رقمية، خذ مصفوفة من T وأعِد مصفوفة من T”. المستدعي لا يكتب <DetailedOrder>؛ يستنتج TypeScript قيمة T من الوسيط (والدرس القادم يشرح كيف بالضبط).
جزءان يقومان بالعمل. T يظهر في المعامل وفي نوع الإرجاع، فيربط بينهما. وextends { totalCents: number } هو constraint (قيد): يحدّ من الأنواع التي يمكن أن يكونها T، وهذا ما يجعل a.totalCents مشروعة داخل الدالة. دونه قد يكون T أي شيء، وسيرفض TypeScript الوصول إلى الخاصية.
قاعدة الظهور مرتين
أنفع اختبار للـ generic، وهو ما أطبّقه في كل مراجعة كود: يجب أن يظهر كل معامل نوع مرتين على الأقل. مرة ليلتقط نوعًا، ومرة ليستخدمه في مكان آخر.
function lastItem<T>(items: T[]): T | undefined { // input → output
return items[items.length - 1];
}
function indexById<T extends { id: number }>(items: T[]): Map<number, T> {
return new Map(items.map((item) => [item.id, item]));
}
function merge<A, B>(a: A, b: B): A & B { // two inputs → output
return { ...a, ...b };
}المعامل الذي يظهر مرة واحدة لا يربط شيئًا. function log<T>(value: T): void هي ببساطة function log(value: unknown): void مع صياغة زائدة، وfunction count<T>(items: T[]): number هي items: unknown[]. احذف معاملات النوع هذه؛ تصبح التوقيعات أسهل قراءة ولا تخسر شيئًا.
المستدعي هو من يختار T
هذا الخطأ يربك الجميع تقريبًا في المرة الأولى:
type Shippable = { weightGrams: number };
function withDefaultWeight<T extends Shippable>(): T {
return { weightGrams: 500 };
// Error: Type '{ weightGrams: number; }' is not assignable to type 'T'.
// '{ weightGrams: number; }' is assignable to the constraint of type 'T', but 'T'
// could be instantiated with a different subtype of constraint 'Shippable'.
}القيد يقول إن T على الأقل Shippable. قد يطلب المستدعي withDefaultWeight<{ weightGrams: number; sku: string }>()، وقيمتك الحرفية ليس فيها sku. داخل الدالة الـ generic، T نوعٌ لا تختاره أنت؛ لا يمكنك إنتاج T إلا من قيم هي T أصلًا، مثل الوسائط. وإن كانت دالتك تبني القيمة بنفسها، فنوع إرجاعها هو القيد (Shippable)، ولا حاجة إلى generic.
قيود تستخدم معاملات أخرى
يمكن أن يشير القيد إلى معامل نوع آخر. المثال الكلاسيكي يختار خاصية باسمها:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const customers = pluck(orders, 'customer'); // string[]
pluck(orders, 'email');
// Error: Argument of type '"email"' is not assignable to parameter of type 'keyof DetailedOrder'.keyof T هو الـ union من أسماء خصائص T، وT[K] هو نوع الخاصية K. ستفكّك الاثنين في القسم 3؛ أما الآن فلاحظ النمط. K يظهر في المعامل وفي نوع الإرجاع، وقيده يربطه بـ T. كل معامل يظهر مرتين على الأقل، وكلٌّ منهما يستحق مكانه.
generic أم union بسيط؟
ليست كل دالة تقبل عدة أنواع بحاجة إلى معامل نوع. اسأل سؤالًا واحدًا: هل يعتمد نوع المخرج على نوع المدخل الذي مرّره المستدعي؟
// Output is always a number, whatever came in: a union is enough.
function toCents(amount: number | string): number {
return Math.round(Number(amount) * 100);
}
// Output depends on the input: a generic says so.
function firstOrThrow<T>(items: T[]): T {
if (items.length === 0) throw new Error('Expected at least one item');
return items[0];
}toCents تُعيد number مهما كان المدخل، فجعلها toCents<T extends number | string>(amount: T): number سيضيف معاملًا يظهر مرة واحدة ولا يربط شيئًا. وfirstOrThrow عكسها: قائمة طلبات يجب أن تُعيد طلبًا، وقائمة منتجات تُعيد منتجًا. أما توقيع الـ union (items: unknown[]): unknown) فسيُجبر كل مستدعٍ على التحويل.
حين لا تكون متأكّدًا، ابدأ دون الـ generic. إذا بدأ المستدعون يكتبون as على النتيجة، فتلك إشارة إلى أن المخرج يعتمد على المدخل، وأن معامل نوع سيُزيل تلك التحويلات.
في التمرين ستحوّل أربع دوال مساعدة تُضيّع المعلومات إلى دوال generic تحتفظ بأنواع المستدعي. والدرس القادم ينظر في كيف يقرّر TypeScript ما هو T حين لا يقوله المستدعي.
أهم النقاط
- يستحق معامل النوع مكانه حين يظهر مرتين على الأقل، رابطًا مدخلًا بمخرج أو مدخلًا بمدخل آخر.
T extends { totalCents: number }تتيح للدالة استخدامtotalCentsمع إعادة النوع الكامل والأكثر تحديدًا الذي مرّره المستدعي.- الـ generic الذي لا يظهر إلا في نوع الإرجاع، مثل
parse<T>(text): T، هو تحويل غير مفحوص بصياغة أجمل. - المستدعي هو من يختار
Tلا الدالة، ولهذا لا يمكنك إعادة كائن حرفي حيث يُنتظرT.