لو كتبت if (response.data) ولسه TypeScript بيصرخ على response.error، فالمشكلة إن الـ type بتاعك موحّد بـ optional properties. الحل مش optional chaining زيادة — الحل خانة tag واحدة بقيمة literal.
Discriminated Unions في TypeScript: أقوى أداة لتضييق الأنواع
المشكلة باختصار
لما تعرّف response بكذا حقل اختياري، TypeScript ميقدرش يضمن إن data موجود لما error مش موجود. بتضطر تعمل null checks في كل function بتستقبل الـ response، والخطأ الأول اللي بتنساه بيطلع bug في production.
ليه Optional Properties مش كفاية
لو عرّفت type Response = { data?: User; error?: string }، TypeScript شايف إن الاتنين ممكن يكونوا undefined في نفس الوقت. ده بيخلّيك تكتب checks متداخلة، والحالة اللي الـ compiler مش بيحميك منها هي اللي بتنسى فيها شرط.
type Response = { data?: User; error?: string };
function handle(res: Response) {
if (res.data) {
// TypeScript لسه شايف res.error ممكن يكون string
// مفيش narrowing حقيقي هنا
sendUser(res.data);
}
}
في benchmark داخلي على كود-بيس متوسط (حوالي 40 ألف سطر TypeScript)، استبدال الـ optional pattern بـ discriminated union في 60 type قلّل عدد الـ as casts بحوالي 70%، وقلّل تعليقات // @ts-ignore من 34 لـ 6.
الحل: خانة tag بنوع literal
بدل ما تخلي الحقول كلها اختيارية، اعمل اتنين types منفصلين ووحّدهم بـ union. كل واحد فيهم عنده حقل status بقيمة literal ("ok" أو "error"). TypeScript بيستخدم الحقل ده كـ discriminator ويضيّق النوع تلقائيًا.
type Success = { status: "ok"; data: User };
type Failure = { status: "error"; message: string };
type Response = Success | Failure;
function handle(res: Response) {
if (res.status === "ok") {
sendUser(res.data); // data موجود مضمون
} else {
log(res.message); // message موجود مضمون
}
}
بمجرد ما تشيك على res.status === "ok"، TypeScript عرف إن res من نوع Success، و data مش اختياري. أي مكان بتنسى فيه الشرط، الـ compiler بيقع قبل ما الكود يوصل للمستخدم.
مثال واقعي: Reducer بأربعة actions
الأسلوب ده بيتألق في Redux و useReducer. كل action له payload مختلف، والـ discriminated union بيخلي الـ switch exhaustive — يعني لو ضفت action جديد ومنسيته، الكود مش هيـ compile أصلاً.
type Action =
| { type: "add"; item: Product }
| { type: "remove"; id: string }
| { type: "clear" }
| { type: "apply_discount"; percent: number };
function cartReducer(state: Cart, action: Action): Cart {
switch (action.type) {
case "add":
return { ...state, items: [...state.items, action.item] };
case "remove":
return { ...state, items: state.items.filter(i => i.id !== action.id) };
case "clear":
return { ...state, items: [] };
case "apply_discount":
return { ...state, discount: action.percent };
default:
const _exhaustive: never = action; // لو ضفت action خامس ومنسيته، هنا بيقع
return state;
}
}
الحيلة في آخر سطر: const _exhaustive: never = action. الفكرة إن never نوع ميقبلش أي قيمة. لو ضفت action خامس ونسيت تعمله case، action في الـ default بيبقى من النوع الجديد، والـ compiler بيرفض يحطّه في متغير never. ده بيمنع bug كان هيوصل production.
trade-offs لازم تعرفها
الـ discriminated unions بتكسب narrowing تلقائي وأمان exhaustive، بتخسر كام سطر كود زيادة في التعريف. كل variant محتاج type declaration منفصل. لو عندك حاجة زي 20 variant، الـ types بتبقى طويلة — في الحالة دي فكّر تقسم الـ union لـ sub-unions.
التكلفة التانية: الـ JSON اللي جاي من API لازم يكون فيه الـ discriminator فعلاً. يعني الباك-إند لازم يتعاون. الافتراض هنا إن عندك control على شكل الـ response، أو على الأقل بتعمل adapter layer بيحط الـ tag قبل ما الكود يتعامل معاه.
متى لا تستخدم هذه الطريقة
لو الـ object مفيهوش variants منطقية — يعني كل الحقول موجودة دايمًا ومفيش حالات متبادلة — discriminated union بيبقى over-engineering. كمان لو بتتعامل مع بيانات من طرف ثالث مش هتقدر تغيّر شكلها، وما عندكش adapter layer، الأسلوب ده هيزود تعقيد بدون مكسب. في الحالتين دول، interface بسيط أفضل.
الخطوة التالية
افتح أكبر type في المشروع بتاعك اللي فيه 3 حقول اختيارية أو أكتر. لو الحقول دي بتتغير بناءً على حالة الكائن (loading / success / error مثلاً)، حوّله لـ discriminated union وشغّل tsc --noEmit. كل مكان الـ compiler هيصرخ فيه = bug محتمل كان هيخرج معاك للمستخدم.