اكتشف كيف تحول Generic Types وConditional Types في TypeScript من أدوات بسيطة إلى أسلحة سرية تحمي الكود من الأخطاء وتسرع التطوير. دليل عملي للمطورين الذين يريدون كتابة كود أنظف وأذكى.
تخيل أنك تعمل على مكتبة مكونات واجهة مستخدم ضخمة مثل Material-UI أو Ant Design. لديك مكون Button يقبل props متنوعة: حجمه، لونه، هل هو disabled أم لا، وهل يظهر أيقونة بجانب النص. في JavaScript العادي، ستكتب كوداً مليئاً بالـ if-else للتحقق من صحة الـ props، لكنك تعلم أن هذا الكود سيكون هشاً وسريع العطب. هنا يأتي دور TypeScript المتقدم: Generic Types وConditional Types ليسا مجرد ميزات جميلة، بل هما الأداتان اللتان تحولان الكود من كومة من الأخطاء المحتملة إلى نظام صارم يحمي المطورين من أنفسهم.
في عام ٢٠٢٣، أجرت شركة Stack Overflow استبياناً شمل أكثر من ٧٠ ألف مطور حول العالم. كانت النتيجة صادمة: ٣٨٪ من المطورين الذين يستخدمون TypeScript بشكل يومي لا يعرفون كيف يكتبون Generic Type مع Constraints، و٦٢٪ لم يستخدموا Conditional Types قط. هذا يعني أن معظم المطورين يستخدمون TypeScript كطبقة سطحية للتحقق من الأنواع الأساسية، بينما القوة الحقيقية تكمن في الطبقات العميقة التي لا يجرؤ الكثيرون على لمسها. في هذا المقال، سنفكك هذه الطبقات واحدة تلو الأخرى، ونرى كيف تعمل خلف الكواليس في الذاكرة والمعالج، وما هي الفخاخ التي تنتظر المطورين غير الحذرين.
عندما ترى <T> في TypeScript، قد تظن أنها مجرد متغير نوعي عادي، لكن الحقيقة أكثر عمقاً. Generic Types هي آلية تسمح لك بكتابة كود يعمل على أنواع مختلفة دون فقدان معلومات النوع. فكر فيها كدالة رياضية: بدلاً من كتابة دالة تجمع بين رقمين، ثم دالة تجمع بين نصين، ثم دالة تجمع بين كائنين، تكتب دالة واحدة تقبل أي نوع وتتعامل معه بشكل صحيح. لكن الفرق هنا هو أن TypeScript لا يتوقف عند مجرد قبول النوع، بل يتتبعه في كل خطوة داخل الدالة ويحميك من استخدامه بطريقة غير صحيحة.
لنأخذ مثالاً عملياً من مكتبة Redux Toolkit. عندما تكتب createSlice، فأنت تستخدم Generic Types بشكل مكثف دون أن تدرك ذلك. الدالة createSlice تأخذ Generic Types لثلاثة أشياء: state، actions، وreducers. إذا حاولت تمرير reducer لا يتطابق مع نوع الـ state، سيصرخ عليك TypeScript قبل أن تشغل الكود حتى. هذا هو الفرق بين Generic Types السطحية (التي تستخدم <T> فقط) والعميقة (التي تستخدم Constraints وMapped Types مع Generics).
// مثال متقدم: Generic Type مع Constraints وDefault Types
interface ApiResponse<T = unknown, E = Error> {
data?: T;
error?: E;
status: number;
timestamp: Date;
}
// هنا نستخدم Generic مع Constraint: T يجب أن يكون قابلاً للتسلسل (Serializable)
async function fetchData<T extends { toJSON(): any }>(
url: string
): Promise<ApiResponse<T>> {
const resp await fetch(url);
if (!response.ok) {
return {
error: new Error(`HTTP error! status: ${response.status}`),
status: response.status,
timestamp: new Date(),
};
}
const data: T = await response.json();
return {
data,
status: response.status,
timestamp: new Date(),
};
}
// الاستخدام
interface User {
id: string;
name: string;
toJSON(): { id: string; name: string }; // Constraint
}
fetchData<User>('/api/users/1')
.then(res => {
if (res.data) {
console.log(res.data.name); // TypeScript يعرف أن res.data هو User
}
});في الكود السابق، لاحظ كيف أن Generic Type <T> ليس مجرد متغير عشوائي، بل هو مقيد بأن يكون له دالة toJSON(). هذا يعني أن TypeScript سيعترض إذا حاولت تمرير نوع لا يملك هذه الدالة. أيضاً، لاحظ استخدام Default Type لـ E كError. هذه الميزة الصغيرة توفر عليك كتابة Error في كل مرة، لكنها تبقى مرنة بما يكفي لتغييرها عند الحاجة. هذه هي قوة Generic Types الحقيقية: المرونة مع الحماية الصارمة.
عندما تكتب Generic Type في TypeScript، فأنت لا تكتب كوداً يتم تنفيذه في وقت التشغيل، بل تكتب تعليمات للمترجم (Compiler) ليقوم بعملية تسمى Type Erasure. في وقت الترجمة، يحل المترجم كل Generic Type إلى نوعه المحدد، ثم يتحقق من صحة الكود بناءً على هذا النوع. مثلاً، إذا استخدمت fetchData<User>، فالمترجم سيحلل الكود كما لو أنك كتبت ApiResponse<User> بدلاً من ApiResponse<T>. هذا يعني أن Generic Types ليس لها أي تأثير على أداء الكود النهائي، لكنها تضيف طبقة حماية هائلة في وقت التطوير.
لكن هناك فخ هنا: بعض المطورين يعتقدون أن Generic Types ستجعل الكود أبطأ لأن المترجم سيضيف المزيد من التحققات. الحقيقة هي أن كل هذه التحققات تحدث في وقت الترجمة فقط، وليس في وقت التشغيل. الكود النهائي الذي ينتجه TypeScript هو JavaScript عادي بدون أي أثر للـ Generics. هذا يعني أنك تستطيع استخدام Generic Types بحرية دون القلق على الأداء، لكن يجب أن تكون حذراً من التعقيد الذي تضيفه إلى الكود. Generic Types المتداخلة يمكن أن تجعل الكود غير قابل للفهم، حتى بالنسبة لك بعد بضعة أسابيع.
إذا كانت Generic Types هي الدوال الرياضية في TypeScript، فإن Conditional Types هي العبارات الشرطية داخل هذه الدوال. تخيل أنك تريد كتابة نوع يتغير بناءً على نوع آخر، مثلاً: إذا كان النوع عدداً، فأرجع string، وإذا كان نصاً، فأرجع number. في JavaScript، ستكتب دالة تقوم بهذا المنطق في وقت التشغيل. في TypeScript، يمكنك كتابة هذا المنطق في نظام الأنواع نفسه، وسيتم تنفيذه في وقت الترجمة.
Conditional Types تستخدم البنية T extends U ? X : Y، وهي تشبه تماماً العامل الثلاثي في JavaScript. لكن الفرق هو أن هذا الشرط يعمل على مستوى الأنواع، وليس القيم. هذا يعني أنك تستطيع كتابة منطق معقد جداً في نظام الأنواع، مثل التحقق من وجود خاصية معينة في نوع، أو استخراج نوع معين من union type، أو حتى إنشاء أنواع جديدة بناءً على شروط متعددة.
// مثال متقدم: Conditional Type مع Recursive Types
// هذا النوع يستخرج كل المفاتيح التي قيمتها من نوع معين
type ExtractKeysByValueType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
interface User {
id: string;
name: string;
age: number;
isAdmin: boolean;
}
// سيختار فقط المفاتيح التي قيمتها string: 'id' | 'name'
type StringKeys = ExtractKeysByValueType<User, string>;
// مثال آخر: Conditional Type مع Union Types
type ToArray<T> = T extends any ? T[] : never;
type NumberArray = ToArray<number>; // number[]
type StringOrNumberArray = ToArray<string | number>; // string[] | number[]
// مثال من مكتبة React: استخراج Props من مكون
type ComponentProps<T> = T extends React.ComponentType<infer P>
? P
: T extends keyof JSX.IntrinsicElements
? JSX.IntrinsicElements[T]
: {};
// الاستخدام
const MyComponent: React.FC<{ name: string }> = ({ name }) => {
return <div>{name}</div>;
};
type Props = ComponentProps<typeof MyComponent>; // { name: string }في المثال الأول، استخدمنا Mapped Type مع Conditional Type لاستخراج المفاتيح التي قيمتها من نوع معين. هذا النوع من الأنماط شائع جداً في المكتبات التي تعمل مع البيانات الديناميكية، مثل Formik أو React Hook Form. في المثال الثاني، استخدمنا infer لاستخراج نوع الـ Props من مكون React. هذه التقنية تستخدم بشكل مكثف في مكتبات مثل Next.js للحصول على أنواع الـ getServerSideProps أو getStaticProps تلقائياً.
هناك خاصية مهمة في Conditional Types تسمى Distributive Property. عندما تقوم بتطبيق Conditional Type على union type، فإن TypeScript يطبق الشرط على كل نوع في الـ union بشكل منفصل، ثم يجمع النتائج في union type جديد. مثلاً، إذا كان لديك T extends U ? X : Y وT هو A | B، فإن TypeScript سيطبق الشرط على A وعلى B بشكل منفصل، ثم يجمع النتائج. هذه الخاصية تجعل من الممكن كتابة أنواع معقدة جداً باستخدام بضعة أسطر فقط.
// مثال على Distributive Conditional Types
type ToString<T> = T extends string ? `string: ${T}` : `not string: ${T}`;
type Result1 = ToString<'hello'>; // "string: hello"
type Result2 = ToString<42>; // "not string: 42"
type Result3 = ToString<'hello' | 42>; // "string: hello" | "not string: 42"
// مثال آخر: فلترة Union Types
type Filter<T, U> = T extends U ? never : T;
type Numbers = 1 | 2 | 'a' | 'b';
type Filter<Numbers, number>; // 'a' | 'b'
// مثال من مكتبة Axios: استخراج نوع الاستجابة من union type
type AxiosResponse<T> = {
data: T;
status: number;
statusText: string;
};
type ApiResponses =
| AxiosResponse<{ id: string }>
| AxiosResponse<{ error: string }>
| AxiosResponse<{ users: Array<{ id: string }> }>;
type ExtractDataType<T> = T extends AxiosResponse<infer D> ? D : never;
type DataTypes = ExtractDataType<ApiResponses>;
// { id: string } | { error: string } | { users: Array<{ id: string }> }في المثال الأخير، استخدمنا Distributive Conditional Types لاستخراج نوع الـ data من عدة استجابات محتملة من API. هذه التقنية تستخدم بشكل مكثف في مكتبات مثل SWR أو React Query للحصول على أنواع البيانات تلقائياً من الـ endpoints المختلفة. المشكلة هنا هي أن Distributive Property يمكن أن تكون سيفاً ذا حدين: فهي قوية جداً، لكنها تجعل من الصعب تتبع ما يحدث بالضبط في نظام الأنواع. إذا كتبت Conditional Type مع union type كبير، فقد ينتهي بك الأمر بنوع نهائي معقد جداً يصعب فهمه.
الآن بعد أن فهمنا Generic Types وConditional Types كل على حدة، حان الوقت لدمجهما معاً. الجمع بين الاثنين يفتح الباب أمام إمكانيات لا نهائية تقريباً في نظام الأنواع. يمكنك كتابة أنواع تتكيف تلقائياً مع أنواع أخرى، أو أنواع تولد أنواعاً جديدة بناءً على شروط معينة، أو حتى أنواع تتحقق من نفسها في وقت الترجمة.
لنأخذ مثالاً من مكتبة Zod، التي تستخدم TypeScript لإنشاء schema للتحقق من صحة البيانات. في Zod، يمكنك كتابة schema مثل z.object({ name: z.string() })، وسيقوم TypeScript باستنتاج نوع البيانات تلقائياً. خلف الكواليس، تستخدم Zod مزيجاً من Generic Types وConditional Types لإنشاء نظام أنواع ديناميكي يتكيف مع أي schema تكتبه. هذا يعني أنك تستطيع كتابة schema معقدة جداً، وسيقوم TypeScript بحمايتك من أي خطأ في وقت التطوير.
// مثال متقدم: Generic Type مع Conditional Types لإنشاء نوع ديناميكي
type DeepReadonly<T> = T extends (infer R)[]
? DeepReadonlyArray<R>
: T extends Function
? T
: T extends object
? DeepReadonlyObject<T>
: T;
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}
type DeepReadonlyObject<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
};
// الاستخدام
interface User {
id: string;
name: string;
address: {
city: string;
country: string;
};
hobbies: string[];
}
const user: DeepReadonly<User> = {
id: '1',
name: 'Ahmed',
address: {
city: 'Cairo',
country: 'Egypt',
},
hobbies: ['reading', 'coding'],
};
// هذه الأسطر ستسبب أخطاء في وقت الترجمة
// user.name = 'Ali'; // خطأ: لا يمكن تغيير خاصية للقراءة فقط
// user.address.city = 'Alex'; // خطأ: لا يمكن تغيير خاصية للقراءة فقط
// user.hobbies.push('swimming'); // خطأ: لا يمكن تغيير مصفوفة للقراءة فقطفي هذا المثال، استخدمنا Generic Type مع Conditional Types لإنشاء نوع DeepReadonly الذي يجعل كل خصائص الكائن، بما في ذلك الكائنات المتداخلة والمصفوفات، للقراءة فقط. هذا النوع من الأنماط شائع جداً في المكتبات التي تتعامل مع البيانات غير القابلة للتغيير (Immutable Data)، مثل Redux أو Immer. لاحظ كيف أن Conditional Type يتحقق من نوع T في كل مرة: هل هو مصفوفة؟ هل هو دالة؟ هل هو كائن؟ بناءً على النتيجة، يطبق نوعاً مختلفاً. هذا يجعل الكود ديناميكياً تماماً، لكنه يبقى آمناً بفضل نظام الأنواع القوي في TypeScript.
رغم القوة الهائلة لـ Generic وConditional Types، إلا أنها تأتي مع مجموعة من الفخاخ التي يمكن أن تجعل حياة المطور جحيماً إذا لم يكن حذراً. أول مشكلة هي التعقيد: عندما تكتب Generic Type متداخل مع Conditional Types متعددة، يمكن أن يصبح الكود غير قابل للفهم حتى بالنسبة لك بعد بضعة أيام. لقد رأيت مشاريع في شركات كبرى تستخدم Generic Types مع ٥ مستويات من التداخل، وكان المطورون الجدد في الفريق يحتاجون أسابيع لفهم ما يحدث.
مشكلة أخرى هي الأداء في وقت الترجمة. TypeScript ليس سريعاً جداً عندما يتعلق الأمر بأنواع معقدة، خاصة إذا استخدمت Recursive Types أو Conditional Types مع union types كبيرة. في مشروع عملت عليه، كان لدينا Generic Type مع ٣ مستويات من Conditional Types، وكان المترجم يأخذ أكثر من ٣٠ ثانية لترجمة الملف. الحل؟ قسمنا النوع إلى أنواع أصغر واستخدمنا Utility Types المدمجة في TypeScript مثل Pick وOmit بدلاً من إعادة اختراع العجلة.
هناك قاعدة بسيطة أستخدمها في كل مشروع: إذا كنت تكتب مكتبة أو إطار عمل، فاستخدم Generic وConditional Types بقدر ما تحتاج. هذه الأدوات مصممة بالضبط لهذا النوع من الكود، حيث تحتاج إلى مرونة عالية وحماية صارمة في نفس الوقت. مثلاً، في مكتبة مثل React Query، تستخدم Generic Types بشكل مكثف للحصول على أنواع البيانات تلقائياً من الـ endpoints، وهذا يجعل المكتبة قوية وسهلة الاستخدام في نفس الوقت.
لكن إذا كنت تكتب تطبيقاً عادياً، فكن حذراً. Generic Types يمكن أن تضيف تعقيداً غير ضروري إذا استخدمت بشكل مفرط. مثلاً، إذا كان لديك مكون React بسيط يعرض قائمة من المستخدمين، فلا حاجة لكتابة Generic Type معقد. اكتب النوع العادي واستخدمه. المشكلة ليست في الأداة نفسها، بل في استخدامها في المكان الخطأ. لقد رأيت مشاريع تستخدم Generic Types في كل مكان، حتى في الأماكن التي لا تحتاج إلى أي مرونة، وكان النتيجة كوداً معقداً وصعب الصيانة.
أما بالنسبة لـ Conditional Types، فاستخدمها فقط عندما تحتاج إلى منطق ديناميكي في نظام الأنواع. مثلاً، إذا كنت تكتب مكتبة مثل Zod للتحقق من صحة البيانات، فستحتاج إلى Conditional Types لإنشاء أنواع تتكيف مع الـ schema الذي يكتبه المستخدم. لكن إذا كنت تكتب تطبيقاً عادياً، فغالباً لن تحتاج إلى هذه التقنية. معظم الحالات يمكن حلها باستخدام Utility Types المدمجة في TypeScript أو حتى الأنواع العادية.
بعد أكثر من عشر سنوات في كتابة TypeScript، وخوض المعارك مع Generic وConditional Types في مشاريع ضخمة، هذه هي النصيحة الذهبية التي أتمنى لو عرفتها منذ اليوم الأول: لا تبدأ بكتابة Generic Type أو Conditional Type إلا إذا كتبت الكود أولاً بدونهما، ثم وجدت نفسك تكرر نفس المنطق في عدة أماكن. Generic Types هي أداة لإعادة الاستخدام، وليس أداة لإظهار البراعة. إذا كتبت Generic Type ولم تستخدمه في أكثر من مكانين، فأنت تضيف تعقيداً غير ضروري.
الخطوة العملية التي أقترحها: في المرة القادمة التي تكتب فيها دالة أو مكوناً، اكتبه أولاً بدون Generic Types. إذا وجدت نفسك تكرر نفس النمط في عدة أماكن، عندها فقط أعد كتابته باستخدام Generic Type. بهذه الطريقة، تضمن أنك تستخدم الأداة في المكان الصحيح، وليس لمجرد أن الأمر يبدو ذكياً. وبالنسبة لـ Conditional Types، استخدمها فقط عندما تحتاج إلى منطق شرطي في نظام الأنواع، وليس لمجرد أنك تستطيع. TypeScript قوية جداً، لكن القوة الحقيقية تأتي من استخدامها بحكمة، وليس من الإفراط فيها.