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

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

الـ Discriminated Unions لحالات المجال

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

الدرس في لمحة

استبدل الأنواع المكدَّسة بالحقول الاختيارية بالـ discriminated unions، كي تحمل كل حالة طلب البيانات التي تملكها بالضبط، وتتوقّف الحالات المستحيلة عن المرور في الترجمة.

  1. 1نوع واحد لكل حالة
  2. 2الـ narrowing على المميِّز
  3. 3الانتقالات دوالٌ بين الحالات
  4. 4مشاركة الحقول المشتركة
  5. 5متى لا تستخدمه

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

هكذا كان شكل نوع Order في Cartwheel قبل أن يفكّر فيه أحد:

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

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

الآن يسمح النوع بخمسة أشكال بالضبط. لاحظ أنه لم يعد شيء اختياريًا: الطلب المشحون له رقم تتبّع. الحالات المستحيلة لا تُفحص وقت التشغيل؛ بل لا يمكن التعبير عنها أصلًا:

TypeScript
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'.
آلة حالات الطلب. processing ينتقل إلى shipped عبر ship، وshipped إلى delivered عبر deliver، وdelivered إلى returned عبر refund. ويمكن أن ينتقل processing أيضًا إلى cancelled عبر cancel. كل صندوق حالة يسرد الحقول التي يضيفها. processing id shipped + trackingNumber delivered + deliveredAt returned + refundCents cancelled + cancelReason ship deliver refund cancel
كل حالة نوعٌ مستقل؛ والانتقالات دوال بينها

الـ narrowing على المميِّز

فحص المميِّز يضيّق الكائن كله. داخل كل case، يعرف TypeScript بالضبط أيّ عضو بين يديك:

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 أكبر ثماره في الدوال التي تغيّر الحالة. حدّد أنواع مدخلاتها ومخرجاتها كأشكال محدّدة:

TypeScript
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 عليه أن يضيّق النوع أولًا:

TypeScript
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: عميل، وطابع زمني لوقت الطلب، وبنود. تكرار هذه في خمسة أشكال يدعو إلى التباعد بينها، فاسحبها إلى نوع أساسي وقاطعه مع حقول كل حالة:

TypeScript
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 فوق المميِّز يحوّل الحالة المنسيّة إلى خطأ ترجمة.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

نوع Order فيه status بخمس قيم وأربعة حقول اختيارية. كم شكلًا تقريبًا يسمح به النوع، وكم منها حالات صالحة إذا كانت كل حالة تستخدم مجموعة ثابتة من الحقول؟

السؤال 2 من 4

بالنظر إلى الـ union أدناه، ما نوع order داخل فرع case 'returned':؟

TypeScript
type Order =
  | { status: 'processing'; id: number }
  | { status: 'delivered'; id: number; deliveredAt: string }
  | { status: 'returned'; id: number; deliveredAt: string; refundCents: number };
السؤال 3 من 4

أيّ توقيع يستخدم الـ union على أفضل وجه لمنع شحن الطلب مرتين؟

السؤال 4 من 4

تضيف الحالة 'on-hold' إلى الـ union المسمّى Order. أيّ دالة مضمونٌ أن تحصل على خطأ ترجمة إلى أن تتعامل مع الحالة الجديدة؟

تدريب

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

حوّل الطلب إلى discriminated union

استبدل Order المليء بالحقول الاختيارية بـ discriminated union من خمسة أشكال (واحد لكل status)، يكون فيه كل حقل تملكه الحالة إلزاميًا، والحقول التي لا تملكها غائبة:

  • processing: id
  • shipped: id، trackingNumber
  • delivered: id، trackingNumber، deliveredAt
  • cancelled: id، cancelReason
  • returned: id، trackingNumber، deliveredAt، refundCents

ثم غيّر ship بحيث لا تقبل إلا طلبًا قيد المعالجة وتُعيد طلبًا مشحونًا. يجب أن تصبح أسطر @ts-expect-error الثلاثة أخطاءً حقيقية، وألّا تحتاج describe إلى ! بعد الآن.

// Every field that only some statuses have is optional,
// so impossible states (shipped with no tracking number…) compile.
type Order = {
  id: number;
  status: 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
  trackingNumber?: string; // shipped, delivered, returned
  deliveredAt?: string;    // delivered, returned
  cancelReason?: string;   // cancelled
  refundCents?: number;    // returned
};

function ship(order: Order, trackingNumber: string): Order {
  return { id: order.id, status: 'shipped', trackingNumber };
}

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)}`;
  }
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<Order['status'], 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned'>>;

// @ts-expect-error: a shipped order must have a tracking number
const noTracking: Order = { id: 1, status: 'shipped' };

// @ts-expect-error: a cancelled order cannot have a delivery date
const cancelledButDelivered: Order = { id: 2, status: 'cancelled', cancelReason: 'Out of stock', deliveredAt: '2025-05-02' };

// @ts-expect-error: only processing orders can be shipped
ship({ id: 3, status: 'delivered', trackingNumber: '1Z-77', deliveredAt: '2025-05-02' }, '1Z-88');

console.log(describe(ship({ id: 1042, status: 'processing' }, '1Z-999')));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ: الحالات المستحيلة الثلاث كلها مرفوضة
  • ship تُعيد طلبًا مشحونًا فيه رقم التتبّع والمعرّف نفسه
  • describe ما زالت تصف الطلبات المرتجعة والملغاة