الوحدة 1 · الأنواع أداةً للتصميم
الـ Discriminated Unions لحالات المجال
19 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
استبدل الأنواع المكدَّسة بالحقول الاختيارية بالـ discriminated unions، كي تحمل كل حالة طلب البيانات التي تملكها بالضبط، وتتوقّف الحالات المستحيلة عن المرور في الترجمة.
- 1نوع واحد لكل حالة
- 2الـ narrowing على المميِّز
- 3الانتقالات دوالٌ بين الحالات
- 4مشاركة الحقول المشتركة
- 5متى لا تستخدمه
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
هكذا كان شكل نوع Order في Cartwheel قبل أن يفكّر فيه أحد:
type Order = {
id: number;
status: 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
trackingNumber?: string; // set once shipped
deliveredAt?: string; // set once delivered
cancelReason?: string; // set if cancelled
refundCents?: number; // set if returned
};يبدو معقولًا، لكنه فخ. كل واحد من الحقول الاختيارية الأربعة يمكن أن يحضر أو يغيب، فيسمح هذا النوع بـ 5 × 2⁴ = 80 شكلًا. وعمل Cartwheel فيه خمسة فقط. طلب مشحون بلا رقم تتبّع يمرّ في الترجمة. طلب ملغى فيه تاريخ تسليم يمرّ. وفي كل مرة تقرأ order.trackingNumber تحصل على string | undefined فتمدّ يدك إلى !، أي أنك تقول للـ compiler “ثق بي” ثمانين مرة في الأسبوع.
نوع واحد لكل حالة
الـ discriminated union (الاتحاد المميَّز) يعطي كل حالة نوع كائن خاصًا بها ويجمعها بـ |. لكل عضو خاصية مشتركة بقيمة حرفية مختلفة، هي المميِّز (discriminant):
type ProcessingOrder = { id: number; status: 'processing' };
type ShippedOrder = { id: number; status: 'shipped'; trackingNumber: string };
type DeliveredOrder = { id: number; status: 'delivered'; trackingNumber: string; deliveredAt: string };
type CancelledOrder = { id: number; status: 'cancelled'; cancelReason: string };
type ReturnedOrder = {
id: number;
status: 'returned';
trackingNumber: string;
deliveredAt: string;
refundCents: number;
};
type Order = ProcessingOrder | ShippedOrder | DeliveredOrder | CancelledOrder | ReturnedOrder;الآن يسمح النوع بخمسة أشكال بالضبط. لاحظ أنه لم يعد شيء اختياريًا: الطلب المشحون له رقم تتبّع. الحالات المستحيلة لا تُفحص وقت التشغيل؛ بل لا يمكن التعبير عنها أصلًا:
const o: Order = { id: 1042, status: 'shipped' };
// Error: Type '{ id: number; status: "shipped"; }' is not assignable to type 'Order'.
// Property 'trackingNumber' is missing in type '{ id: number; status: "shipped"; }'
// but required in type 'ShippedOrder'.الـ narrowing على المميِّز
فحص المميِّز يضيّق الكائن كله. داخل كل case، يعرف TypeScript بالضبط أيّ عضو بين يديك:
function describe(order: Order): string {
switch (order.status) {
case 'processing':
return `#${order.id} is being packed`;
case 'shipped':
return `#${order.id} is on its way (${order.trackingNumber})`;
case 'delivered':
return `#${order.id} arrived on ${order.deliveredAt}`;
case 'cancelled':
return `#${order.id} was cancelled: ${order.cancelReason}`;
case 'returned':
return `#${order.id} was refunded ${(order.refundCents / 100).toFixed(2)}`;
}
}لا ! ولا ?. ولا ?? ''. ولاحظ نوع الإرجاع الصريح : string. إذا أضاف أحدهم الحالة 'on-hold' إلى الـ union الشهر القادم، تصبح نهاية هذه الدالة قابلة للوصول، فيبلّغ TypeScript عن Function lacks ending return statement and return type does not include ‘undefined’. الـ compiler يجد كل مكان يحتاج إلى تحديث. وستجعل هذا الفحص صريحًا وأعلى صوتًا باستخدام never في الـ Branded Types والفحوص الشاملة.
الانتقالات دوالٌ بين الحالات
يؤتي الـ union أكبر ثماره في الدوال التي تغيّر الحالة. حدّد أنواع مدخلاتها ومخرجاتها كأشكال محدّدة:
function ship(order: ProcessingOrder, trackingNumber: string): ShippedOrder {
return { id: order.id, status: 'shipped', trackingNumber };
}
function cancel(order: ProcessingOrder, reason: string): CancelledOrder {
return { id: order.id, status: 'cancelled', cancelReason: reason };
}الآن صار “لا يمكنك شحن طلب مرتين” و"لا يمكنك إلغاء طلب مشحون" حقيقتين وقت الترجمة. المستدعي الذي بين يديه Order عليه أن يضيّق النوع أولًا:
function shipIfReady(order: Order, tracking: string): Order {
if (order.status !== 'processing') return order;
return ship(order, tracking); // order is ProcessingOrder here
}أنواع الإرجاع مهمة بقدر المعاملات. لأن ship تُعيد ShippedOrder لا Order، يستطيع المستدعي تسليم النتيجة مباشرة إلى دالة تحتاج رقم تتبّع، مثل printLabel(shipped)، دون فحص status مرة أخرى. كل انتقال يحمل المعرفة إلى الأمام. أعِد الـ Order الواسع وستكون قد رميت تلك المعرفة، وأجبرت كل مستدعٍ على التضييق من جديد.
مشاركة الحقول المشتركة
للطلبات الحقيقية أكثر من id: عميل، وطابع زمني لوقت الطلب، وبنود. تكرار هذه في خمسة أشكال يدعو إلى التباعد بينها، فاسحبها إلى نوع أساسي وقاطعه مع حقول كل حالة:
type OrderBase = { id: number; customerId: number; placedAt: string };
type Shipped = OrderBase & { status: 'shipped'; trackingNumber: string };
type Cancelled = OrderBase & { status: 'cancelled'; cancelReason: string };يبقى كل شكل عضوًا مستقلًا بـ status حرفي خاص به، فيعمل الـ narrowing تمامًا كما من قبل. ويعطيك النوع الأساسي أيضًا نوع معامل طبيعيًا للدوال التي لا تهتم بالحالة، مثل function customerLink(order: OrderBase)، التي تقبل كل الأشكال.
يمكنك أيضًا تفكيك المميِّز. منذ TypeScript 4.4، تضيّق const { status } = order; if (status === 'shipped') { … } النوعَ order أيضًا، ما دام order ثابتًا const أو معاملًا لم يُعَد الإسناد إليه. هذا يُبقي جمل switch الطويلة مقروءة دون التخلّي عن الـ narrowing.
متى لا تستخدمه
الـ discriminated union هو الخيار الصحيح حين تحمل الحالات بيانات مختلفة. أما حين تختلف الأشكال في التسمية فقط (مثلًا channel بقيم 'web' | 'mobile_app' | 'marketplace' والحقول نفسها)، فيكفي union حرفي بسيط على خاصية واحدة. الجأ إلى النمط الكامل حين تضبط نفسك تكتب حقولًا اختيارية مع تعليقات مثل “تُضبط فقط حين…”.
في التمرين، ستحوّل Order المليء بالحقول الاختيارية في Cartwheel إلى union، وتجعل ship لا تقبل إلا الطلبات القابلة للشحن. الدرس القادم: الـ literal types وas const وsatisfies، الأدوات التي تمنع تلك القيم الحرفية من الاتساع مجددًا إلى string.
أهم النقاط
- النوع المكوَّن من حقول اختيارية يسمح بكل تركيباتها، ومعظمها حالات لا يمكن أن تقع في عملك أبدًا.
- الـ discriminated union يعطي كل حالة نوع كائن خاصًا بها، وتربط بينها خاصية حرفية مشتركة مثل
status. - فحص المميِّز بـ
===أوswitchيضيّق الكائن كله، فتصبح الحقول الخاصة بكل حالة متاحة دون!. - الدوال التي تنقل بين الحالات يجب أن تقبل وتُعيد أشكالًا محدّدة، مثل
ship(order: ProcessingOrder): ShippedOrder. - نوع الإرجاع الصريح على جملة
switchفوق المميِّز يحوّل الحالة المنسيّة إلى خطأ ترجمة.