الوحدة 1 · الأنواع أداةً للتصميم
الـ Structural Typing وقابلية الإسناد
17 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
توقّع متى يقبل TypeScript نوعًا حيث يُنتظر نوع آخر، بما في ذلك فحوص الخصائص الزائدة ومعاملات الدوال، كي تكفّ أخطاء قابلية الإسناد عن مفاجأتك.
- 1الأنواع مجموعات من القيم
- 2فحوص الخصائص الزائدة: الاستثناء الوحيد
- 3الدوال: المعاملات تقلب الاتجاه
- 4حين تريد أن تكون للأسماء قيمة
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
إليك دالة من عميل الدفع في Cartwheel، واستدعاءً يفاجئ القادمين من Java أو C#:
type Money = { amountCents: number; currency: string };
function formatMoney(m: Money): string {
return `${(m.amountCents / 100).toFixed(2)} ${m.currency}`;
}
const tent = { sku: 'TENT-2P', name: 'Two-person tent', amountCents: 18900, currency: 'USD' };
formatMoney(tent); // compiles: "189.00 USD"لم يُعرَّف tent أبدًا على أنه Money؛ إنه منتج. ومع ذلك يقبله TypeScript، لأنه لا يسأل عن اسم الشيء، بل يسأل إن كان له الشكل الصحيح. هذا هو الـ structural typing (التنميط البنيوي)، وحين تراه بوضوح تختفي معظم لحظات “لماذا يمرّ هذا الكود؟”.
الأنواع مجموعات من القيم
هذا أنفع نموذج ذهني في الدورة كلها: الـ type مجموعة من القيم. string هو مجموعة كل النصوص. 'USD' مجموعة فيها قيمة واحدة. و'USD' | 'EUR' فيها قيمتان.
قابلية الإسناد (assignability) هي علاقة الاحتواء. يمكن وضع قيمة من النوع A حيث يُنتظر B حين تكون كل A هي أيضًا B. كل 'USD' نصٌّ، لذا 'USD' قابل للإسناد إلى string. وليس كل نص هو 'USD'، لذا يحتاج العكس إلى فحص.
أنواع الكائنات تعمل بالطريقة نفسها، مع لمسة تُربك الناس. { id: number } هو مجموعة كل القيم التي لها id رقمي. و{ id: number; status: string } يضيف شرطًا ثانيًا، فتقلّ القيم المؤهَّلة. خصائص أكثر تعني مجموعة أصغر. لهذا يمكن تمرير Order كامل حيث يُنتظر { id: number }: إنه في المجموعة الأصغر، فهو إذن في الأكبر أيضًا.
يقف نوعان خاصّان عند الأطراف. unknown هو مجموعة كل القيم: كل شيء قابل للإسناد إليه، وعليك أن تفحص قبل استخدامه. وnever هو المجموعة الفارغة: قابل للإسناد إلى كل شيء (فالمجموعة الفارغة جزء من كل مجموعة)، ولا شيء قابل للإسناد إليه. ستعتمد على الاثنين لاحقًا في الدورة. أما any فليس مجموعة أصلًا؛ إنه يُطفئ الفحص في الاتجاهين، ولهذا يستطيع any واحد شارد أن يسمّم بهدوء كل ما يلمسه.
فحوص الخصائص الزائدة: الاستثناء الوحيد
إن كانت الخصائص الإضافية مقبولة، فلماذا يفشل هذا؟
type Order = { id: number; status: string };
function save(order: Order) {}
save({ id: 1001, stauts: 'processing' });
// Error: Object literal may only specify known properties, but 'stauts'
// does not exist in type 'Order'. Did you mean to write 'status'?هذا فحص الخصائص الزائدة (excess property check)، وهو قاعدة تقديرية مقصودة فوق التنميط البنيوي. حين تكتب كائنًا حرفيًا مباشرة حيث يُنتظر نوع، يكون الكائن “طازجًا”: لا شيء آخر يمكن أن يراه، فالخاصية الإضافية لا يمكن أن تكون إلا خطأً إملائيًا. لذلك يعلّمها TypeScript.
ولا ينطبق الفحص إلا على الكائنات الحرفية الطازجة:
const draft = { id: 1001, status: 'processing', stauts: 'oops' };
save(draft); // compiles: draft is not freshالدوال: المعاملات تقلب الاتجاه
في الدوال، تتبع أنواع القيم المُعادة القاعدة المعتادة، لكن المعاملات تسير في الاتجاه الآخر. الـ handler الذي يقبل أي CheckoutEvent يمكن أن يحلّ محلّ handler لا يحتاج إلا PaymentFailed. والعكس غير آمن: handler لا يفهم إلا PaymentFailed سينهار أمام حدث shipped. تحت strict (وتحديدًا strictFunctionTypes)، يرفضه TypeScript:
type PaymentFailed = { type: 'payment-failed'; reason: string };
type OrderShipped = { type: 'order-shipped'; carrier: string };
type CheckoutEvent = PaymentFailed | OrderShipped;
type Listener = { onEvent: (e: CheckoutEvent) => void };
const failuresOnly = { onEvent: (e: PaymentFailed) => console.log(e.reason.toUpperCase()) };
const l: Listener = failuresOnly;
// Error: Type '{ onEvent: (e: PaymentFailed) => void; }' is not assignable to type 'Listener'.
// Types of property 'onEvent' are incompatible.المعاملات الأقل مقبولة أيضًا: (e) => {} و() => {} كلتاهما تناسبان (e: CheckoutEvent, at: Date) => void، لأن الدالة مسموح لها بتجاهل الوسائط. ولهذا تعمل orders.forEach((o) => …) رغم أن forEach تمرّر ثلاثة وسائط.
ثغرة صيغة الـ method
اكتب الآن النوع نفسه بصيغة الـ method:
interface Listener {
onEvent(e: CheckoutEvent): void; // method syntax
}
const l: Listener = failuresOnly; // compiles!معاملات الـ methods المعرَّفة بهذه الطريقة تُفحص فحصًا ثنائي التغاير (bivariant)، حتى تحت strict. يُبقي TypeScript هذا عن قصد كي تبقى الأنماط اليومية تعمل، مثل معاملة Array<Dog> على أنها Array<Animal>. والثمن أن الـ listener غير الآمن أعلاه يُقبَل.
حين تريد أن تكون للأسماء قيمة
أحيانًا يكون التنميط البنيوي كريمًا أكثر من اللازم. OrderId وCustomerId كلاهما number، فلا شيء يمنعك من تبديلهما. الحل أن تجعل الشكلين مختلفين، وهذا ما تفعله الـ branded types في الـ Branded Types والفحوص الشاملة. أما الآن فتكفيك القاعدة العامة: إن كان لشيئين الشكل نفسه، يعتبرهما TypeScript الشيء نفسه.
التمرين أدناه يسدّ ثغرة الـ listener في ناقل الأحداث في Cartwheel. وبعده سترى كيف يضيّق TypeScript نوعًا واسعًا داخل الدالة، النصف الآخر من العمل مع المجموعات.
أهم النقاط
- يقارن TypeScript الأشكال لا الأسماء؛ أي قيمة تملك الخصائص المطلوبة قابلة للإسناد، أيًّا كان اسم نوعها.
- اقرأ الـ type على أنه مجموعة القيم التي يسمح بها؛ نوع الكائن الذي فيه خصائص مطلوبة أكثر يصف مجموعة أصغر.
- فحوص الخصائص الزائدة لا تنطبق إلا على الكائنات الحرفية الطازجة، ولهذا يمرّ تمرير متغيّر فيه حقل إضافي.
- تُفحص معاملات الدوال في الاتجاه المعاكس، لكن الـ methods المعرَّفة بصيغة الـ method تُفحص فحصًا ثنائي التغاير (bivariant)، لذا فضّل صيغة الخاصية للـ callbacks.