EduFlow
أنماط TypeScript المتقدّمة
⁦0%⁩

الوحدة 2 · الـ Generics بعمق

الـ Generics بوصفها علاقات

18 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

اكتب دوالًا generic تربط معاملاتُ النوع فيها المدخلات بالمخرجات، وقيّدها بـ extends، وتعرّف على الـ generics التي هي في الحقيقة تحويلات غير مفحوصة.

  1. 1معامل النوع علاقة
  2. 2قاعدة الظهور مرتين
  3. 3المستدعي هو من يختار T
  4. 4قيود تستخدم معاملات أخرى
  5. 5generic أم union بسيط؟

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

إليك دالة مساعدة من صفحة سجل الطلبات في Cartwheel. أنواعها محدّدة، وتمرّ في الترجمة، وترمي المعلومات بهدوء:

TypeScript
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:

TypeScript
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، وهو ما أطبّقه في كل مراجعة كود: يجب أن يظهر كل معامل نوع مرتين على الأقل. مرة ليلتقط نوعًا، ومرة ليستخدمه في مكان آخر.

TypeScript
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

هذا الخطأ يربك الجميع تقريبًا في المرة الأولى:

TypeScript
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.

قيود تستخدم معاملات أخرى

يمكن أن يشير القيد إلى معامل نوع آخر. المثال الكلاسيكي يختار خاصية باسمها:

TypeScript
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 بسيط؟

ليست كل دالة تقبل عدة أنواع بحاجة إلى معامل نوع. اسأل سؤالًا واحدًا: هل يعتمد نوع المخرج على نوع المدخل الذي مرّره المستدعي؟

TypeScript
// 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.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

ما نوع sorted؟

TypeScript
type DetailedOrder = { id: number; totalCents: number; customer: string };
declare const orders: DetailedOrder[];
function sortByTotal(xs: { totalCents: number }[]) { return [...xs].sort((a, b) => a.totalCents - b.totalCents); }
const sorted = sortByTotal(orders);
السؤال 2 من 3

أيّ هذه التوقيعات يستخدم معامل النوع بطريقة تضيف أمانًا حقيقيًا في الأنواع؟

السؤال 3 من 3

لماذا يُعدّ هذا خطأً؟

TypeScript
function emptyCart<T extends { items: string[] }>(): T {
  return { items: [] };
}

تدريب

بيئة تجريب الكود

اجعل الدوال المساعدة تحتفظ بأنواعها

هذه الدوال المساعدة الأربع من صفحة سجل الطلبات في Cartwheel تعمل وقت التشغيل لكنها تُضيّع معلومات الأنواع. اجعل كل واحدة منها generic حتى تنجح اختبارات الأنواع:

  • sortByTotal تُعيد نسخة مرتّبة نوعها هو نوع عنصر المستدعي، وتبقى ترفض الكائنات التي ليس فيها totalCents.
  • indexById تُعيد Map من المعرّف إلى نوع عنصر المستدعي.
  • pluck(items, key) لا تقبل إلا أسماء خصائص حقيقية، وتُعيد مصفوفة من نوع تلك الخاصية.
  • lastItem تُعيد العنصر الأخير، بنوع العنصر أو undefined.

أبقِ السلوك وقت التشغيل كما هو، ولا تستخدم any أو as.

function sortByTotal(orders: { totalCents: number }[]): { totalCents: number }[] {
  return [...orders].sort((a, b) => a.totalCents - b.totalCents);
}

function indexById(items: { id: number }[]): Map<number, { id: number }> {
  return new Map(items.map((item) => [item.id, item]));
}

function pluck(items: any[], key: string): any[] {
  return items.map((item) => item[key]);
}

function lastItem(items: unknown[]): unknown {
  return items[items.length - 1];
}

// ---- Type tests (do not edit) ----
type DetailedOrder = { id: number; totalCents: number; customer: string };
const orders: DetailedOrder[] = [
  { id: 1003, totalCents: 4500, customer: 'Mona' },
  { id: 1001, totalCents: 18900, customer: 'Karim' },
  { id: 1002, totalCents: 1200, customer: 'Lea' },
];

const sorted = sortByTotal(orders);
const byId = indexById(orders);
const customers = pluck(orders, 'customer');
const last = lastItem(orders);

type T1 = Expect<Equal<typeof sorted, DetailedOrder[]>>;
type T2 = Expect<Equal<typeof byId, Map<number, DetailedOrder>>>;
type T3 = Expect<Equal<typeof customers, string[]>>;
type T4 = Expect<Equal<typeof last, DetailedOrder | undefined>>;

// @ts-expect-error: products have no totalCents
sortByTotal([{ sku: 'TENT-2P' }]);

// @ts-expect-error: 'email' is not a property of DetailedOrder
pluck(orders, 'email');

console.log(sorted.map((o) => o.customer), byId.get(1001)?.customer, customers, last?.id);
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • sortByTotal ترتّب تصاعديًا دون تعديل مدخلها
  • indexById تربط كل معرّف بعنصره
  • pluck وlastItem ما زالتا تعملان وقت التشغيل