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

الوحدة 3 · البرمجة على مستوى الأنواع

keyof و typeof وأنواع الوصول بالفهرس

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

الدرس في لمحة

اشتقّ الأنواع من قيم وقت التشغيل ومن أنواع أخرى باستخدام typeof وkeyof والوصول بالفهرس، كي يسري التغيير في مكان واحد عبر قاعدة الكود بدل أن تتباعد النُّسخ.

  1. 1typeof: من القيم إلى الأنواع
  2. 2keyof: أسماء الخصائص
  3. 3الوصول بالفهرس: نوع الخاصية
  4. 4لماذا تُعيد Object.keys النوع string[]

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

مناطق الشحن في Cartwheel موجودة في كائن إعداد. النسخة الأولى من الكود وصفتها مرتين: مرة كبيانات، ومرة كأنواع.

TypeScript
const SHIPPING_ZONES = {
  domestic: { countries: ['US', 'CA'], feeCents: 499 },
  gulf: { countries: ['AE', 'SA'], feeCents: 1299 },
  europe: { countries: ['GB', 'DE'], feeCents: 999 },
};

type Zone = 'domestic' | 'gulf' | 'europe'; // written by hand

بعد ستة أشهر أضاف أحدهم منطقة levant إلى الكائن ونسي النوع. لم يفشل شيء في الترجمة؛ كل ما في الأمر أن المنطقة الجديدة لم يكن ممكنًا اختيارها في أي مكان. كل نسخة مكتوبة يدويًا من حقيقةٍ ما هي نسخة قابلة للتباعد. هذا الدرس يغطّي عوامل الأنواع (type operators) الثلاثة التي تتيح لك كتابة الحقيقة مرة واحدة واشتقاق الباقي.

typeof: من القيم إلى الأنواع

في موضع النوع، تأخذ typeof قيمة وتعطيك النوع الذي استنتجه TypeScript لها. إنها ليست العامل typeof في JavaScript (الذي يُعيد نصوصًا مثل 'object' وقت التشغيل)؛ إنها لا توجد إلا وقت الترجمة.

TypeScript
const SHIPPING_ZONES = {
  domestic: { countries: ['US', 'CA'], feeCents: 499 },
  gulf: { countries: ['AE', 'SA'], feeCents: 1299 },
  europe: { countries: ['GB', 'DE'], feeCents: 999 },
} as const;

type ShippingZones = typeof SHIPPING_ZONES;
// { readonly domestic: { readonly countries: readonly ['US', 'CA']; readonly feeCents: 499 }; … }

as const مهمة هنا، كما كانت في الـ Literal Types و as const و satisfies. دونها سيكون countries من نوع string[] وfeeCents من نوع number، وستفقد التفاصيل الجديرة بالاشتقاق.

تعمل typeof مع الدوال أيضًا: typeof formatPrice هو التوقيع الكامل للدالة، ويمكنك تمريره إلى utility types (أنواع مساعدة) مثل ReturnType<typeof formatPrice>. ستبني ReturnType بنفسك بعد درسين.

متى يجب أن تكون القيمة هي المصدر والنوع مشتقًا، لا العكس؟ استخدم typeof حين تكون البيانات هي التعريف: جداول البحث، وإعدادات المناطق، وقوائم الخيارات التي تُعرض أيضًا في الواجهة. واكتب النوع أولًا حين يكون الشكل عقدًا مع طرف آخر، مثل حمولة API، وافحص القيم مقابله.

عالمان. في عالم القيم يقع الكائن SHIPPING_ZONES. تنقله typeof إلى عالم الأنواع كنوع. ومن هناك، تنتج keyof الـ union من أسماء المناطق، والوصول بالفهرس بتلك الأسماء ينتج الـ union من إعدادات المناطق، والفهرسة الأعمق تعطي الرسوم. القيم (وقت التشغيل) SHIPPING_ZONES typeof الأنواع (وقت الترجمة) Zones keyof → 'domestic' | … [K]['feeCents'] → 499 | …
typeof تعبر من القيم إلى الأنواع؛ وkeyof والفهرسة تعملان على الأنواع

keyof: أسماء الخصائص

keyof T هو الـ union من أسماء خصائص T:

TypeScript
type Zone = keyof typeof SHIPPING_ZONES; // 'domestic' | 'gulf' | 'europe'

أضف levant إلى الكائن فيتحدّث Zone من تلقاء نفسه. وهذا هو المقصود كله.

تفصيلان يستحقان المعرفة. أولًا، مع نوع فيه index signature مثل Record<string, number>، تعطي keyof النوع string (وstring | number مع { [k: string]: … }، لأن JavaScript تحوّل المفاتيح الرقمية إلى نصوص). ثانيًا، keyof على union تعطي فقط المفاتيح التي يملكها كل عضو. keyof (ShippedOrder | CancelledOrder) هو 'id' | 'status'، لأن قراءة trackingNumber من شيء قد يكون طلبًا ملغى ليست آمنة.

الوصول بالفهرس: نوع الخاصية

T[K] تبحث عن نوع الخاصية K في T، بصياغة الأقواس المربّعة نفسها المستخدمة للوصول إلى الخصائص وقت التشغيل:

TypeScript
type GulfZone = ShippingZones['gulf'];             // { readonly countries: …; readonly feeCents: 1299 }
type GulfFee = ShippingZones['gulf']['feeCents'];  // 1299
type ZoneConfig = ShippingZones[Zone];             // union of all three configs
type FeeCents = ShippingZones[Zone]['feeCents'];   // 499 | 1299 | 999
type ShippingCountry = ShippingZones[Zone]['countries'][number]; // 'US' | 'CA' | 'AE' | …

الفهرسة بـ union تعطي union من النتائج، فيكون ShippingZones[Zone] هو “نوع قيمة أي منطقة”. والتعبير (typeof X)[keyof typeof X] هو طريقتك للحصول على الـ union من قيم الكائن، ويتكرّر بما يكفي لأن تسمّيه قواعد كود كثيرة ValueOf<T>.

المصفوفات والـ tuples تُفهرس بـ number: T[number] هو نوع العنصر. هذا هو (typeof COUNTRIES)[number] الذي استخدمته في القسم 1، وهو أيضًا السطر الأخير أعلاه، الذي يحفر عبر مستويين من الكائنات ومصفوفة واحدة ليجمع كل دولة في كل منطقة.

الحيلة نفسها تعمل على أنواع API لم تكتبها أنت. إن أعطاك عميل مولَّد نوعًا كبيرًا اسمه CheckoutResponse، يمكنك تسمية نوع البند بـ CheckoutResponse['lines'][number] بدل نسخ حقوله.

لماذا تُعيد Object.keys النوع string[]

عاجلًا أو آجلًا ستكتب هذا وتنزعج:

TypeScript
for (const zone of Object.keys(SHIPPING_ZONES)) {
  SHIPPING_ZONES[zone]; // Error: 'string' can't be used to index type …
}

تُعيد Object.keys النوع string[]، لا Zone[]، وهذا مقصود. تذكّر التنميط البنيوي: القيمة التي نوعها { a: number } قد تكون كائنًا فيه عشر خصائص أخرى وقت التشغيل. لو وعدت Object.keys بـ (keyof T)[]، لكانت تكذب بشأن تلك العشر.

مع كائن const عرّفته بنفسك، لا سبيل لتسلّل مفاتيح إضافية إليه، يكون التحويل المحلّي معقولًا وصادقًا: Object.keys(SHIPPING_ZONES) as Zone[]. أما مع الكائنات القادمة من مكان آخر، فأبقِ string وافحص كل مفتاح.

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

أهم النقاط

  • في موضع النوع، تعطي typeof value النوع الذي استنتجه TypeScript لتلك القيمة، فتكون جسرًا من كود وقت التشغيل إلى نظام الأنواع.
  • keyof T هو الـ union من أسماء خصائص T، وT[K] هو نوع الخاصية (أو الخصائص) التي يسمّيها K.
  • (typeof CONFIG)[keyof typeof CONFIG] يعطي الـ union من أنواع قيم الكائن، وT[number] يعطي نوع عنصر المصفوفة.
  • تُعيد Object.keys النوع string[] عن قصد: التنميط البنيوي يعني أن الكائن قد يحمل مفاتيح أكثر مما يسرده نوعه.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

ما هو Zone؟

TypeScript
const SHIPPING_ZONES = {
  domestic: { feeCents: 499 },
  gulf: { feeCents: 1299 },
} as const;
type Zone = keyof typeof SHIPPING_ZONES;
السؤال 2 من 4

type CheckoutResponse = { lines: { sku: string; qty: number }[] }. أيّ نوع يساوي { sku: string; qty: number }؟

السؤال 3 من 4

ما هو keyof (ShippedOrder | CancelledOrder) حين يملك كلاهما id وstatus، ولا يملك trackingNumber إلا ShippedOrder، ولا يملك cancelReason إلا CancelledOrder؟

السؤال 4 من 4

لماذا تُعيد Object.keys(order) النوع string[] بدل (keyof Order)[]؟

تدريب

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

اشتقّ أنواع الشحن

استبدل الأنواع الخمسة المكتوبة يدويًا بأنواع مشتقّة من SHIPPING_ZONES وCheckoutResponse، بحيث تتحدّث تلقائيًا عند إضافة منطقة أو تغيير نوع الـ API:

  • Zone: أسماء المناطق.
  • ShippingCountry: كل دولة في كل منطقة.
  • FeeCents: رسوم كل منطقة، كأرقام حرفية.
  • LineItem: عنصر واحد من CheckoutResponse['lines'].
  • CheckoutStatus: نوع status في الطلب.

ثم اجعل zoneFor وfeeFor تمرّان في الترجمة بالأنواع الجديدة (بلا any).

const SHIPPING_ZONES = {
  domestic: { countries: ['US', 'CA'], feeCents: 499 },
  gulf: { countries: ['AE', 'SA'], feeCents: 1299 },
  levant: { countries: ['EG', 'JO'], feeCents: 899 },
  europe: { countries: ['GB', 'DE'], feeCents: 999 },
} as const;

// Shape of the checkout API response (generated from the backend's schema).
type CheckoutResponse = {
  order: { id: number; status: 'processing' | 'shipped'; totalCents: number };
  lines: { sku: string; quantity: number; unitPriceCents: number }[];
};

type Zone = string;
type ShippingCountry = string;
type FeeCents = number;
type LineItem = unknown;
type CheckoutStatus = string;

function zoneFor(country: ShippingCountry): Zone {
  for (const [zone, config] of Object.entries(SHIPPING_ZONES)) {
    if ((config.countries as readonly string[]).includes(country)) return zone;
  }
  throw new Error(`No shipping zone for ${country}`);
}

function feeFor(country: ShippingCountry): FeeCents {
  return SHIPPING_ZONES[zoneFor(country)].feeCents;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<Zone, 'domestic' | 'gulf' | 'levant' | 'europe'>>;
type T2 = Expect<Equal<ShippingCountry, 'US' | 'CA' | 'AE' | 'SA' | 'EG' | 'JO' | 'GB' | 'DE'>>;
type T3 = Expect<Equal<FeeCents, 499 | 1299 | 899 | 999>>;
type T4 = Expect<Equal<LineItem, { sku: string; quantity: number; unitPriceCents: number }>>;
type T5 = Expect<Equal<CheckoutStatus, 'processing' | 'shipped'>>;

function typeTests() {
  // @ts-expect-error: France is not in any shipping zone
  zoneFor('FR');
}

console.log(zoneFor('JO'), feeFor('SA'));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • zoneFor تجد المنطقة الصحيحة لدولة ما
  • feeFor تُعيد رسوم المنطقة