Discriminated Unions في TypeScript: امسك الحالة الناقصة قبل الإنتاج
هتكسب من المقال ده طريقة عملية تخلي TypeScript يقولك إنك نسيت حالة جديدة قبل ما الكود يوصل للـ QA أو الإنتاج.
مستوى القارئ: متوسط
المشكلة باختصار
الطريقة الشائعة الغلط إنك تعمل نوع عام فيه حقول اختيارية كتير، وبعدها تعتمد على الذاكرة داخل switch. الطريقة دي بتفشل لما تضيف حالة جديدة بعد شهرين وتنسى تعالجها في شاشة أو API response.
مثال واقعي: عندك checkout فيه حالات دفع: pending، paid، failed. بعدين ضفت refunded. لو شاشة الفاتورة ما اتحدثتش، المستخدم هيشوف رسالة ناقصة أو فرع default عام. في فريق صغير، ده ممكن يظهر 5 أو 6 مرات في الشهر مع كل توسعة للـ state machine.
الفكرة بمثال بسيط
ركز في المثال ده. بدل ما تقول إن الدفع كائن واحد فيه كل الحقول ممكنة، خليه مجموعة حالات واضحة. كل حالة لها status ثابت، وحقولها الخاصة.
بالظبط كأنك عندك 4 نماذج ورقية مختلفة. ورقة الدفع الناجح فيها receiptId. ورقة الفشل فيها reason. مينفعش تطلب receiptId من ورقة فشل، لأن الورقة أصلاً مش مصممة لكده.
التعريف العلمي: Discriminated Union هو union من object types تشترك في حقل مميز واحد، غالبًا اسمه type أو status. TypeScript يستخدم قيمة الحقل ده عشان يعمل narrowing، يعني يضيّق النوع داخل كل فرع.
الكود العملي
ابدأ بتعريف الحالات كده. الافتراض إنك بتستخدم TypeScript في مشروع React أو Node.js، وstrict مفعّل في tsconfig.json.
type PaymentState =
| { status: "pending"; startedAt: string }
| { status: "paid"; receiptId: string; amountCents: number }
| { status: "failed"; reason: string }
| { status: "refunded"; refundId: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function renderPaymentMessage(payment: PaymentState): string {
switch (payment.status) {
case "pending":
return `Payment started at ${payment.startedAt}`;
case "paid":
return `Paid ${payment.amountCents / 100} with receipt ${payment.receiptId}`;
case "failed":
return `Payment failed: ${payment.reason}`;
case "refunded":
return `Refunded with id ${payment.refundId}`;
default:
return assertNever(payment);
}
}اللي بيحصل فعلاً: داخل فرع paid، TypeScript يعرف إن payment عنده receiptId وamountCents. داخل فرع failed، نفس المتغير بقى نوعه مختلف وفيه reason. ده مش magic runtime. ده تحليل static مبني على قيمة status.
جرّب تحذف فرع refunded من switch. مع assertNever، TypeScript هيطلع خطأ لأن payment في default لم يعد never. دي أفضل طريقة تخلي إضافة الحالة الجديدة تكسر الـ build بدل ما تكسر تجربة المستخدم.
القياس والتأثير
في مشروع checkout متوسط فيه 8 شاشات بتقرأ نفس حالة الدفع، الخطأ الشائع إنك تضيف حالة جديدة في الـ backend وتنسى شاشة أو اتنين. قبل النمط ده، اكتشاف الحالة الناقصة غالبًا بيحصل في test يدوي أو bug report. بعده، الخطأ يظهر في tsc --noEmit خلال ثواني.
npm install -D typescript
npx tsc --noEmitرقم عملي معقول: لو الـ CI بياخد دقيقتين، فأنت نقلت اكتشاف bug من يوم أو أسبوع بعد merge إلى دقيقتين داخل pull request. في الرسم فوق استخدمت تقدير 6 حالات ناقصة شهريًا قبل الفحص الشامل مقابل حالة واحدة بعده. الرقم تقديري، لكنه مفيد كطريقة تفكير: الهدف مش تسريع runtime، الهدف تقليل bugs الناتجة من تغيير الـ state.
الـ trade-off هنا
المكسب: كود أوضح، autocompletion أفضل، وأخطاء أقل عند إضافة حالات جديدة. الخسارة: تعريف الأنواع هيبقى أطول شوية، ولازم الفريق يلتزم بحقل مميز ثابت مثل status. لو عندك objects جاية من API غير موثوق، TypeScript وحده لا يكفي، لأن النوع بيختفي وقت التشغيل.
بدل ما تعتمد على Discriminated Unions فقط مع بيانات خارجية، استخدم validation runtime مثل Zod أو Valibot عند حدود النظام. بعد التحقق، مرّر النوع الموثوق لباقي التطبيق.
متى لا تستخدم هذه الطريقة
لا تستخدمها لو الحالات عندك مفتوحة وغير معروفة وقت التطوير، مثل plugins خارجية تضيف types جديدة من غير release واضح. لا تستخدمها كبديل عن validation للـ JSON القادم من API. ولا تبالغ فيها لو عندك حالتين بسيطتين جدًا وكود القراءة في مكان واحد فقط.
مصادر اعتمد عليها المقال
الخطوة التالية
افتح أكثر type عندك فيه status أو kind، وحوّله إلى Discriminated Union. بعد كده شغّل npx tsc --noEmit. لو ظهر خطأ في switch، ده مش إزعاج. ده bug كان مستني يطلع في الإنتاج.