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

الوحدة 1 · الأنواع أداةً للتصميم

الـ Structural Typing وقابلية الإسناد

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

الدرس في لمحة

توقّع متى يقبل TypeScript نوعًا حيث يُنتظر نوع آخر، بما في ذلك فحوص الخصائص الزائدة ومعاملات الدوال، كي تكفّ أخطاء قابلية الإسناد عن مفاجأتك.

  1. 1الأنواع مجموعات من القيم
  2. 2فحوص الخصائص الزائدة: الاستثناء الوحيد
  3. 3الدوال: المعاملات تقلب الاتجاه
  4. 4حين تريد أن تكون للأسماء قيمة

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

إليك دالة من عميل الدفع في Cartwheel، واستدعاءً يفاجئ القادمين من Java أو C#:

TypeScript
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 يحتوي string، الذي يحتوي الـ union من USD وEUR، الذي يحتوي USD. على اليمين: كائن فيه id يحتوي كائنًا فيه id وstatus، الذي يحتوي Order كاملًا. كل قيمة في مجموعة داخلية تنتمي أيضًا إلى المجموعات المحيطة بها. unknown string 'USD' | 'EUR' 'USD' { id } { id, status } Order id, status, total…
قابلية الإسناد هي علاقة الاحتواء: الأنواع الأضيق تقع داخل الأوسع

يقف نوعان خاصّان عند الأطراف. unknown هو مجموعة كل القيم: كل شيء قابل للإسناد إليه، وعليك أن تفحص قبل استخدامه. وnever هو المجموعة الفارغة: قابل للإسناد إلى كل شيء (فالمجموعة الفارغة جزء من كل مجموعة)، ولا شيء قابل للإسناد إليه. ستعتمد على الاثنين لاحقًا في الدورة. أما any فليس مجموعة أصلًا؛ إنه يُطفئ الفحص في الاتجاهين، ولهذا يستطيع any واحد شارد أن يسمّم بهدوء كل ما يلمسه.

فحوص الخصائص الزائدة: الاستثناء الوحيد

إن كانت الخصائص الإضافية مقبولة، فلماذا يفشل هذا؟

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

ولا ينطبق الفحص إلا على الكائنات الحرفية الطازجة:

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:

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:

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

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

هل يمرّ هذا الكود في الترجمة؟

TypeScript
type Money = { amountCents: number; currency: string };
const product = { sku: 'TENT-2P', amountCents: 18900, currency: 'USD' };
const price: Money = product;
السؤال 2 من 4

إذا فكّرت في الأنواع على أنها مجموعات من القيم، فأيّ العبارات صحيحة؟

السؤال 3 من 4

لماذا يمرّ هذا الكود في الترجمة تحت strict؟

TypeScript
type CheckoutEvent = { type: 'paid' } | { type: 'shipped'; carrier: string };
interface Listener { onEvent(e: CheckoutEvent): void }
const l: Listener = { onEvent: (e: { type: 'shipped'; carrier: string }) => {} };
السؤال 4 من 4

أيّ استدعاء يرفضه فحص الخصائص الزائدة؟

تدريب

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

سُدّ ثغرة الـ listener

يعرّف CheckoutListener الدالة onEvent بصيغة الـ method، فيفحص TypeScript معاملها فحصًا ثنائي التغاير (bivariant) ويقبل بلا تردّد failuresOnly، وهو listener سينهار أمام حدث OrderShipped. غيّر طريقة تعريف onEvent (لا نوع معاملها) بحيث يصبح إسناد failuresOnly إلى CheckoutListener خطأ نوع، مع بقاء استدعاءَي subscribe الموجودين يمرّان في الترجمة ووصول حدث الشحن إليهما.

type PaymentFailed = { type: 'payment-failed'; orderId: number; reason: string };
type OrderShipped = { type: 'order-shipped'; orderId: number; carrier: string };
type CheckoutEvent = PaymentFailed | OrderShipped;

// Cartwheel's checkout event bus.
interface CheckoutListener {
  onEvent(event: CheckoutEvent): void;
}

const listeners: CheckoutListener[] = [];
const seen: string[] = [];

function subscribe(listener: CheckoutListener): void {
  listeners.push(listener);
}

function emit(event: CheckoutEvent): void {
  for (const l of listeners) l.onEvent(event);
}

// Handles every event: must keep compiling.
subscribe({ onEvent: (e) => { seen.push(`${e.type}:${e.orderId}`); } });

// A listener that takes no arguments at all: must keep compiling too.
subscribe({ onEvent: () => { seen.push('ping'); } });

// ---- Type tests (do not edit) ----
// This listener only understands PaymentFailed; an OrderShipped event would
// crash it (e.reason is undefined). Subscribing it must be a type error.
const failuresOnly = { onEvent: (e: PaymentFailed) => { seen.push(e.reason.toUpperCase()); } };
// @ts-expect-error: a PaymentFailed-only listener cannot receive every CheckoutEvent
const unsafe: CheckoutListener = failuresOnly;

emit({ type: 'order-shipped', orderId: 1042, carrier: 'DHL' });
console.log(seen);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ: الـ listener غير الآمن مرفوض
  • كلا الـ listeners الآمنَين ما زال يستقبل حدث الشحن