Menu
flag Ar iconالعربيةdown icon

Discriminated Unions في TypeScript: شرح الـ unions الموسومة

الـ discriminated union هو union من أنواع كائنات تشترك في خاصية وسم حرفية، مثل kind أو status. فحص الوسم يضيّق الكائن كله. تعرّف على النمط، والتضييق بـ switch، والفحص الشامل بـ never، وكيف تنمذج نتائج API وحالة الطلبات وآلات الحالة.

تحتوي هذه الصفحة على محررات قابلة للتشغيل - حرّر، شغّل، وشاهد النتيجة فوراً.

الـ discriminated union (ويُسمى أيضًا tagged union) هو union من أنواع كائنات تشترك كلها في خاصية واحدة، هي الوسم، بقيمة حرفية مختلفة في كل عضو. فحص الوسم يخبر TypeScript بالعضو الذي لديك، فتتاح بقية خصائص ذلك العضو.

داخل case "circle" تكون قراءة shape.width خطأ ترجمة، لأن عضو الدائرة لا يملك width. الوسم بيانات عادية: وقت التشغيل هو مجرد خاصية نصية، وswitch هو JavaScript عادية.

ما الذي يجعل union مميَّزًا

يعمل التضييق على الوسم عندما تتحقق ثلاثة شروط:

  1. كل عضو نوع كائن.
  2. كل عضو يملك اسم الخاصية نفسه (المميّز). الاسم متروك لك: kind وtype وstatus وtag شائعة.
  3. في كل عضو، يكون لتلك الخاصية نوع حرفي: قيمة حرفية نصية أو رقمية أو منطقية، أو null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

إذا أعلن أحد الأعضاء الوسم كـ string عادي بدل قيمة حرفية، تتوقف مقارنة الوسم عن تضييق الـ union، وتبقى الخصائص الخاصة بكل عضو بعيدة المنال (TS2339).

التضييق على الوسم

أي فحص تفهمه TypeScript على الوسم يضيّق الكائن: switch، وif/else، و=== و!==، بل والوسم المفكك ما دام const:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

فحص وجود خاصية بـ "radius" in shape يضيّق أيضًا، لكن مقارنة الوسم أوضح في القراءة وتجعل الفحص الشامل ممكنًا.

الفحص الشامل

أكبر فائدة للنمط تظهر عندما يكبر الـ union. مع نوع قيمة مُعادة صريح ودون default، تعرف TypeScript أن switch يجب أن يغطي كل وسم، فيكون العضو الجديد الذي بلا case خطأ ترجمة:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

لا تقول هذه الرسالة أي حالة غائبة. أما الدالة المساعدة assertNever فتقول ذلك، وترمي أيضًا وقت التشغيل إذا حملت بيانات من الخارج (JSON أو عميل أقدم) وسمًا تقول الأنواع إنه لا يمكن أن يوجد:

أضف حالة خامسة دون case فيبلّغ السطر assertNever(state) عن Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never'، مسمّيًا العضو. المزيد في صفحة never.

جعل الحالات المستحيلة مستحيلة

يحل RequestState أعلاه محل شكل شائع وأضعف:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

مع الحقول الاختيارية، تجتاز حالة «تحميل مع بيانات وخطأ» فحص الأنواع، ويضطر كل قارئ إلى تخمين التركيبات الممكنة فعلًا. أما مع discriminated union فلا توجد data إلا في حالة success ولا يوجد error إلا في error، فالكود الذي يقرأ state.data يجب أن يثبت أولًا أنه في حالة success. النوع نفسه يوثّق الحالات الصالحة.

نمذجة النتائج: نجاح أو فشل

وسم منطقي يكفي لـ «نجح أو لم ينجح». نوع Result هذا بديل شائع عن رمي الاستثناءات للإخفاقات المتوقعة مثل المدخلات غير الصحيحة:

لا يستطيع المستدعي قراءة result.value دون فحص result.ok أولًا، وهذا بالضبط الفحص الذي يسهل نسيانه مع الاستثناءات أو مع إعادة null.

آلات الحالة والـ Reducers

اثنان من discriminated unions، أحدهما للحالات والآخر للأفعال، يصفان آلة حالة. يعمل الـ reducer بـ switch على الفعل ويعيد الحالة التالية؛ ويتحقق المترجم من أن كل كائن مُعاد حالة صالحة:

لا يُترجم { ...state, name: "paused" } إلا لأن state ضُيّق أولًا إلى عضو التشغيل، فتحمل النسخة track وposition. أما إعادة { name: "paused" } وحدها فستكون خطأ: حالة الإيقاف تحتاج إلى مقطع. وهذا الشكل نفسه الذي تستخدمه reducers في Redux وuseReducer في React.

فخ التوسيع

يجب أن يبقى الوسم نوعًا حرفيًا. الكائن المبني في متغير دون تعليق نوع يُوسَّع وسمه إلى string، فلا يطابق أي عضو:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

القاعدة الأساسية، أي أن الخصائص القابلة للتغيير تتوسع، مشروحة في صفحة الأنواع الحرفية.

الأسئلة الشائعة

ما هو discriminated union في TypeScript؟

union من أنواع كائنات يملك كل عضو فيه الخاصية نفسها (المميّز أو الوسم) بنوع حرفي مختلف، مثل { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. فحص shape.kind === "circle" يضيّق shape إلى عضو الدائرة، فتتاح خصائصه الأخرى.

ما الفرق بين union و discriminated union؟

الـ discriminated union هو union بقاعدة إضافية واحدة: تشترك كل الأعضاء في خاصية بنوع حرفي فريد. الـ union العادي مثل Cat | Fish يجب تضييقه بـ in أو بـ type guards مخصصة؛ أما discriminated union فيُضيَّق بمقارنة خاصية واحدة، ويمكن التحقق من شمولية switch على تلك الخاصية.

كيف أجعل switch على discriminated union شاملًا؟

إما أن تعطي الدالة نوع قيمة مُعادة صريحًا دون default (فتكون الحالة الغائبة الخطأ TS2366)، أو تضيف default: return assertNever(value) مع function assertNever(x: never): never { throw ... }. الصيغة الثانية تسمّي العضو غير المعالج في الخطأ، وترمي أيضًا وقت التشغيل إذا وصلت بيانات غير متوقعة.

لماذا لا يتضيّق الـ discriminated union لدي؟

يجب أن يكون الوسم نوعًا حرفيًا في كل عضو. إذا أُنشئ كائن دون تعليق نوع، يُوسَّع kind فيه إلى string فلا يطابق أي عضو (TS2345/TS2322). أصلح ذلك بتعليق نوع، أو بـ as const، أو بإنشائه حيث يُتوقع نوع الـ union. والعضو الذي نوع وسمه kind: string يمنع أيضًا تضييق الـ union على kind.

هل يمكن أن يكون المميّز boolean أو رقمًا؟

نعم. أي نوع حرفي يعمل: النصوص (kind: "circle")، والأرقام (version: 2)، والقيم المنطقية (ok: true / ok: false)، بل وnull أو undefined. الأوسمة النصية هي الأكثر شيوعًا لأنها مقروءة عند تسجيلها أو إرسالها كـ JSON.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن