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