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

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

كيف يعمل الـ Inference، ومعاملات النوع const

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

الدرس في لمحة

توقّع ما يستنتجه TypeScript لمعامل النوع، واحتفظ بالأنواع الحرفية عبر القيود ومعاملات النوع const، ورتّب وسائط الـ callbacks كي يسري الـ inference في الاتجاه الصحيح.

  1. 1مواقع الاستنتاج والمرشّحون
  2. 2متى تتّسع القيم الحرفية
  3. 3معاملات النوع const
  4. 4الـ callbacks تُستنتج أخيرًا

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

في الدرس السابق كتبت sortByTotal(orders) فاستنتج TypeScript أن T هو DetailedOrder. في معظم الأحيان يفعل الـ inference (استنتاج النوع) ما تريده، ولهذا تبدو المرات التي لا يفعل فيها عشوائية. لكنها ليست كذلك. يتبع TypeScript مجموعة صغيرة من القواعد، وحين تعرفها تستطيع تصميم توقيعات تستنتج بالضبط النوع الذي تقصده، دون أي <…> في موضع الاستدعاء.

مواقع الاستنتاج والمرشّحون

كل موضع يظهر فيه معامل النوع داخل نوع معامل هو موقع استنتاج (inference site). عند الاستدعاء، يطابق TypeScript كل وسيط مع نوع معامله، ويجمع مرشّحًا (candidate) لمعامل النوع من كل موقع. ثم يختار نوعًا واحدًا من بين المرشّحين.

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

مسار الاستنتاج. كل وسيط من الوسيطين في موضع الاستدعاء ينتج مرشّحًا لـ T عبر موقع استنتاجه. يُدمج المرشّحون ويُوسَّعون وفق القواعد في T واحد مختار، يُعوَّض بعدها في نوع الإرجاع. a: 'processing' b: 'shipped' المرشّحون دمج، ثم اتساع؟ T المختار string return: T[]
من الوسائط إلى النوع المختار إلى نوع الإرجاع

متى تتّسع القيم الحرفية

القاعدة الثانية تفسّر معظم لحظات “لماذا حصلت على string؟”. المرشّح الحرفي مثل 'processing' يُحتفظ به أو يُوسَّع حسب التوقيع:

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

TypeScript
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. أما الآن فيمكن للدالة أن تطلب ذلك بنفسها:

TypeScript
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. وداخل الكائن الحرفي الواحد، يمرّ على الخصائص الحسّاسة للسياق من اليسار إلى اليمين:

TypeScript
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 عن استنتاج البقية.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

ما نوع b؟

TypeScript
function box<T>(value: T): { value: T } { return { value }; }
const b = box('processing');
السؤال 2 من 4

أيّ تعريف يجعل defineFlow(['processing', 'shipped']) تُعيد النوع readonly ['processing', 'shipped'] دون أن يكتب المستدعي as const؟

السؤال 3 من 4

لماذا نوع d هنا هو unknown؟

TypeScript
createStep({
  render: (d) => d.totalCents.toFixed(),
  load: (orderId) => ({ orderId, totalCents: 4500 }),
});
السؤال 4 من 4

تُستدعى function convert<From, To>(value: From, to: (v: From) => To): To هكذا: convert<string>('12', Number). ماذا يحدث؟

تدريب

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

احتفظ بالقيم الحرفية للمستدعي

ثلاث دوال تعريف مساعدة في نظام الدفع في Cartwheel تُضيّع الأنواع الدقيقة التي يكتبها مستدعوها. اجعل اختبارات الأنواع تنجح دون تغيير الاستدعاءات (بلا as const في مواضع الاستدعاء):

  • defineFlow(steps) تُعيد الخطوات كـ tuple للقراءة فقط من الحالات الحرفية، وتبقى ترفض الحالات المجهولة.
  • oneOf(values) تُعيد type guard: بعد if (isChannel(raw)) يضيق raw إلى القيم الحرفية الممرَّرة.
  • defineFlags(flags) تحتفظ بالشكل الدقيق لكل علم (بما فيه الـ tuple المسمّى countries) مع فحص كل علم مقابل Flag.

الدوال الثلاث تُعيد وسيطها دون تغيير وقت التشغيل (وoneOf تُعيد دالة فحص).

type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Flag = { rollout: number; countries: readonly string[] };

function defineFlow(steps: OrderStatus[]) {
  return steps;
}

function oneOf(values: readonly string[]) {
  return (input: string): boolean => values.includes(input);
}

function defineFlags(flags: Record<string, Flag>) {
  return flags;
}

// ---- Type tests (do not edit) ----
const happyPath = defineFlow(['processing', 'shipped', 'delivered']);
type T1 = Expect<Equal<typeof happyPath, readonly ['processing', 'shipped', 'delivered']>>;

// @ts-expect-error: 'refunded' is not an order status
defineFlow(['processing', 'refunded']);

const isChannel = oneOf(['web', 'mobile_app', 'marketplace']);
const raw: string = 'mobile_app';
if (isChannel(raw)) {
  type T2 = Expect<Equal<typeof raw, 'web' | 'mobile_app' | 'marketplace'>>;
}

const flags = defineFlags({
  expressShipping: { rollout: 0.5, countries: ['EG', 'AE'] },
  giftWrap: { rollout: 1, countries: [] },
});
type T3 = Expect<Equal<typeof flags.expressShipping.countries, readonly ['EG', 'AE']>>;

// @ts-expect-error: every flag needs a rollout
defineFlags({ applePay: { countries: ['US'] } });

console.log(happyPath, isChannel('web'), isChannel('fax'), flags.expressShipping.countries);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • oneOf تُعيد دالة لا تقبل إلا القيم المدرَجة
  • defineFlow وdefineFlags تُعيدان وسيطهما دون تغيير