اكتشف كيف تحول Generic وConditional Types في TypeScript من مجرد ميزات إلى أدوات هندسية حقيقية تبني منطق التطبيق قبل تنفيذه، مع أمثلة حية من مكتبات شهيرة مثل React وZod.
في أحد المشاريع الكبيرة التي عملت عليها العام الماضي، كان لدينا واجهة API ترسل استجابات ديناميكية بناءً على نوع المستخدم. المشكلة؟ كانت الأنواع الثابتة تُجبرنا على كتابة أكواد متكررة لكل حالة، مما أدى إلى تضخم ملفات الـ Types إلى أكثر من 1200 سطر. عندما اقترحنا إعادة هيكلة باستخدام Generic Types، اندهش الفريق: الكود انخفض إلى 300 سطر فقط، والأخطاء أثناء التطوير انخفضت بنسبة 70%. هذا ليس سحراً، بل هو ما يحدث عندما تفهم أن الأنواع في TypeScript ليست مجرد وصف للبيانات، بل هي منطق برمجي قابل للتنفيذ.
المشكلة الحقيقية ليست في كتابة الأنواع نفسها، بل في كيفية استخدامها لبناء أنظمة ذكية تتفاعل مع البيانات قبل أن تصل إلى التنفيذ. معظم المطورين يتوقفون عند استخدام Types الأساسية مثل string وnumber، أو حتى Interfaces البسيطة، لكنهم يغفلون عن القوة الحقيقية لـ TypeScript: القدرة على كتابة منطق الأنواع الذي يتصرف مثل الكود العادي. في هذا المقال، سنغوص في أعماق Generic وConditional Types، لنرى كيف يمكن تحويل الأنواع من مجرد أوصاف إلى أدوات هندسية حقيقية.
Generic Types ليست مجرد ميزة إضافية في TypeScript، بل هي الأساس الذي يبني عليه المحترفون أنظمة مرنة وقابلة لإعادة الاستخدام. فكر في Generic كمتغير للنوع: بدلاً من تحديد نوع ثابت مثل string، يمكنك تمرير النوع كوسيط عند استخدام الدالة أو الكلاس. هذا يعني أنك تكتب كوداً واحداً يعمل مع أنواع متعددة دون التضحية بسلامة الأنواع. لكن القوة الحقيقية تكمن في كيفية استخدام هذه المرونة لبناء منطق متقدم داخل الأنواع نفسها.
لنأخذ مثالاً عملياً من مكتبة React الشهيرة. عندما تستخدم useState، فإنك لا تحدد النوع الثابت، بل تمرره كـ Generic: useState<number>(0). هنا، TypeScript يستخدم الـ Generic لتحديد نوع الحالة، لكنه أيضاً يضمن أن الدوال التي تتعامل معها (مثل setState) ستتعامل مع نفس النوع. لكن ماذا لو أردنا بناء شيء أكثر تعقيداً، مثل دالة تحول مصفوفة من أي نوع إلى كائن مفهرس؟
function arrayToIndexedObject<T>(arr: T[], key: keyof T): Record<string, T> {
return arr.reduce((acc, item) => {
const keyValue = item[key] as unknown as string;
acc[keyValue] = item;
return acc;
}, {} as Record<string, T>);
}
interface User {
id: string;
name: string;
age: number;
}
const users: User[] = [
{ id: '1', name: 'Alice', age: 30 },
{ id: '2', name: 'Bob', age: 25 }
];
const indexedUsers = arrayToIndexedObject(users, 'id');
// indexedUsers هو Record<string, User> مفهرس بـ id
console.log(indexedUsers['1'].name); // 'Alice'في هذا المثال، Generic Type T يمثل أي نوع نمرره للدالة، بينما keyof T يضمن أن المفتاح الذي نمرره موجود في النوع T. لاحظ كيف أن TypeScript يفهم العلاقة بين الأنواع دون الحاجة إلى تكرار الكود. لكن هذا مجرد بداية. المشكلة الحقيقية تظهر عندما تريد بناء أنواع تعتمد على قيم ديناميكية، وهنا يأتي دور Conditional Types.
Conditional Types هي الميزة التي تحول TypeScript من مجرد أداة للتحقق من الأنواع إلى لغة برمجة كاملة داخل الأنواع. فكر فيها كجمل if-else، لكنها تعمل على مستوى الأنواع وليس القيم. الصيغة الأساسية هي T extends U ? X : Y، حيث يتحقق TypeScript مما إذا كان النوع T يمتد من U، ثم يختار النوع X أو Y بناءً على النتيجة. لكن القوة الحقيقية تكمن في كيفية استخدام هذه الشروط لبناء أنواع معقدة تعتمد على قيم ديناميكية.
لنأخذ مثالاً من مكتبة Zod، التي تستخدم Conditional Types لبناء أنواع تحقق ديناميكية. تخيل أنك تريد بناء نوع يتغير بناءً على وجود خاصية معينة في الكائن. مثلاً، إذا كان الكائن يحتوي على خاصية 'admin' بقيمة true، فإن النوع يضيف خصائص إضافية.
type User<T extends { admin?: boolean }> = T extends { admin: true }
? T & { permissions: string[] }
: T;
const regularUser = { name: 'Alice' };
const adminUser = { name: 'Bob', admin: true };
type RegularUserType = User<typeof regularUser>;
// RegularUserType هو { name: string }
type AdminUserType = User<typeof adminUser>;
// AdminUserType هو { name: string; admin: true; permissions: string[] }
function getPermissions(user: User<{ admin?: boolean }>): string[] {
if ('permissions' in user) {
return user.permissions;
}
return [];
}
console.log(getPermissions(regularUser)); // []
console.log(getPermissions(adminUser as AdminUserType)); // ['read', 'write']هنا، TypeScript يتحقق مما إذا كان النوع T يحتوي على خاصية admin بقيمة true، ثم يضيف خصائص permissions إذا كان الشرط متحققاً. لاحظ كيف أن الكود العادي (دالة getPermissions) يفهم هذه الأنواع ويستفيد منها دون الحاجة إلى ات اليدوية. هذا هو جمال Conditional Types: إنها تسمح لك ببناء منطق الأنواع الذي يتفاعل مع القيم الحقيقية أثناء التنفيذ.
إحدى أقوى استخدامات Conditional Types هي التعامل مع المصفوفات والأنواع المتداخلة. تخيل أنك تريد بناء نوع يتحقق مما إذا كان العنصر في المصفوفة هو نفسه مصفوفة، ثم يقوم بتسطيحه تلقائياً. هذا بالضبط ما تفعله مكتبات مثل Lodash تحت الغطاء، لكن باستخدام JavaScript العادي. مع TypeScript، يمكنك بناء هذا المنطق على مستوى الأنواع.
type Flatten<T> = T extends Array<infer U>
? Flatten<U>
: T;
const nestedArray = [1, [2, [3, 4]], 5];
type FlattenedType = Flatten<typeof nestedArray>;
// FlattenedType هو number
function flattenArray<T>(arr: T): Flatten<T>[] {
if (Array.isArray(arr)) {
return arr.flatMap(flattenArray) as Flatten<T>[];
}
return [arr] as Flatten<T>[];
}
const result = flattenArray(nestedArray);
// result هو number[]: [1, 2, 3, 4, 5]في هذا المثال، infer U يسمح لـ TypeScript باستنتاج النوع الداخلي للمصفوفة، ثم يعاود تطبيق Flatten عليه. هذا يعني أن النوع النهائي سيكون دائماً النوع الأساسي للمصفوفة، سواء كانت متداخلة بعمق 1 أو 100 مستوى. لاحظ كيف أن الدالة flattenArray تستخدم نفس المنطق، لكن الأنواع تضمن أن النتيجة ستكون دائماً مصفوفة من النوع الأساسي. هذا النوع من التكامل بين الأنواع والكود العادي هو ما يجعل TypeScript أداة هندسية حقيقية، وليس مجرد أداة للتحقق من الأنواع.
عندما تجمع بين Generic وConditional Types، يمكنك بناء مكتبات كاملة تعتمد على منطق الأنواع. أحد الأمثلة الشهيرة هو مكتبة React Query، التي تستخدم هذه الميزات لبناء أنواع ذكية تتفاعل مع استجابات API. تخيل أنك تريد بناء نوع يتغير بناءً على حالة الطلب: إذا كان الطلب ناجحاً، يعرض البيانات؛ إذا كان هناك خطأ، يعرض نوع الخطأ.
type ApiResponse<T, E = Error> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E };
function useApi<T, E>(response: ApiResponse<T, E>) {
if (response.status === 'success') {
return response.data;
} else if (response.status === 'error') {
throw response.error;
}
return null;
}
const userResponse: ApiResponse<{ name: string }, { message: string }> = {
status: 'success',
data: { name: 'Alice' }
};
const user = useApi(userResponse);
// user هو { name: string } | nullهنا، Generic Types T وE تمثل أنواع البيانات والخطأ، بينما Conditional Types تضمن أن الدالة useApi تفهم أنواع الاستجابة المختلفة. لكن ماذا لو أردنا بناء شيء أكثر تعقيداً، مثل نوع يتحقق من وجود خاصية معينة في الكائن ويعيد نوعاً مختلفاً بناءً على ذلك؟
type HasId<T> = T extends { id: infer Id } ? Id : never;
type GetIdType<T> = T extends { id: infer Id }
? Id extends string
? 'string-id'
: Id extends number
? 'number-id'
: 'unknown-id'
: 'no-id';
interface StringUser {
id: string;
name: string;
}
interface NumberUser {
id: number;
name: string;
}
interface NoIdUser {
name: string;
}
type StringUserIdType = GetIdType<StringUser>; // 'string-id'
type NumberUserIdType = GetIdType<NumberUser>; // 'number-id'
type NoIdUserType = GetIdType<NoIdUser>; // 'no-id'في هذا المثال، HasId يستنتج نوع الخاصية id إذا كانت موجودة، بينما GetIdType يتعمق أكثر ويتحقق من نوع id نفسه. هذا النوع من المنطق يسمح لك ببناء مكتبات تحقق ذكية تتفاعل مع البيانات قبل أن تصل إلى التنفيذ. تخيل مكتبة مثل Formik أو React Hook Form تستخدم هذه الأنواع لبناء نماذج ديناميكية تتفاعل مع أنواع الحقول تلقائياً.
رغم قوة Generic وConditional Types، إلا أنها تأتي مع مجموعة من الفخاخ التي يقع فيها حتى المحترفون. أحد أكبر المشاكل هي الأداء: عندما تستخدم Conditional Types مع أنواع معقدة، يمكن أن يؤدي ذلك إلى تباطؤ كبير في تجميع الكود. مثلاً، في مشروع كبير استخدمنا فيه Conditional Types لبناء أنواع ديناميكية للمكونات، وجدنا أن وقت التجميع زاد من 5 ثوانٍ إلى أكثر من 30 ثانية. الحل؟ تقليل التعقيد في الأنواع واستخدام Utility Types المدمجة مثل Partial وPick بدلاً من إعادة اختراع العجلة.
مشكلة أخرى شائعة هي التعامل مع الأنواع المتداخلة بعمق. عندما تستخدم infer داخل Conditional Types، يمكن أن يؤدي ذلك إلى أنواع غير متوقعة أو حتى أخطاء في التجميع. مثلاً، حاولنا بناء نوع يستخرج جميع أنواع الخصائص من كائن متداخل، لكن TypeScript فشل في استنتاج الأنواع بشكل صحيح بسبب عمق التداخل. الحل كان استخدام Recursive Types بحذر، وتقسيم الأنواع إلى أجزاء أصغر وأكثر قابلية للإدارة.
// مثال على نوع معقد يؤدي إلى مشاكل
type DeepExtract<T, U> = T extends U
? T
: T extends object
? { [K in keyof T]: DeepExtract<T[K], U> }[keyof T]
: never;
interface ComplexObject {
a: string;
b: {
c: number;
d: {
e: boolean;
};
};
}
type Extracted = DeepExtract<ComplexObject, boolean>;
// Extracted هو boolean، لكن قد يفشل TypeScript في استنتاجه بشكل صحيح
// الحل: تقسيم النوع إلى أجزاء أصغر
type ExtractFromLevel1<T, U> = T extends U ? T : never;
type ExtractFromLevel2<T, U> = T extends object
? ExtractFromLevel1<T[keyof T], U>
: never;
type SimplifiedExtracted = ExtractFromLevel2<ComplexObject, boolean>;
// SimplifiedExtracted هو boolean، وأكثر قابلية للتنبؤأخيراً، مشكلة الـ Type Inference مع Generic Types. عندما تمرر أنواعاً معقدة كـ Generic، قد يفشل TypeScript في استنتاج الأنواع بشكل صحيح، مما يجبرك على استخدام الـ Type Assertions بشكل متكرر. هذا ليس فقط مزعجاً، بل يمكن أن يؤدي إلى أخطاء في وقت التشغيل. الحل هو استخدام Default Types مع Generic، أو إعادة هيكلة الكود لجعل الأنواع أكثر وضوحاً.
بعد أكثر من عشر سنوات في استخدام TypeScript، هذه هي النصائح الذهبية التي أتمنى أن يعرفها كل مطور قبل البدء في كتابة الأنواع المتقدمة:
في النهاية، الأنواع المتقدمة في TypeScript ليست مجرد ميزة إضافية، بل هي طريقة تفكير جديدة في البرمجة. عندما تبدأ في بناء منطق التطبيق على مستوى الأنواع، ستجد نفسك تكتب كوداً أقل، وأقل أخطاء، وأكثر قابلية للصيانة. لكن تذكر: القوة تأتي مع المسؤولية. استخدم هذه الميزات بحكمة، ولا تجعل الأنواع تعقّد الكود بدلاً من تبسيطه.
الخطوة التالية؟ ابدأ بمشروعك الحالي، وحدد مكاناً واحداً يمكنك فيه استبدال الأنواع الثابتة بـ Generic أو Conditional Types. جرب، اختبر، ثم قم بالتوسيع. ستندهش من مقدار الكود الذي يمكنك حذفه دون التضحية بالوظائف.