هل تعتقد أنك تتقن TypeScript لأنك تعرف الفرق بين interface وtype؟ الحقيقة هي أن المحترفين لا يتوقفون عند الأساسيات. في هذا المقال، سنغوص في أعماق Advanced Types ونكشف كيف تحول Generic وConditional Types الكود من مجرد كتابة إلى فن هندسي، مع أمثلة حقيقية من مكتبات مثل React وZod.
في أحد المشاريع الكبيرة التي عملت عليها مع فريق في شركة ناشئة في دبي، كنا نكتب مكتبة للتعامل مع الـ API Responses الديناميكية. المشكلة؟ كان الـ Backend يرسل بيانات تختلف بناءً على الـ Query Parameters — أحياناً يكون الـ Response هو User، وأحياناً يكون Array<User>، وأحياناً Error. حاولنا استخدام Union Types البسيطة، لكن الكود تحول إلى كابوس من الـ Type Assertions والـ Any Types. حينها أدركت أن Union Types وحدها لا تكفي. كنا بحاجة إلى شيء أذكى: Conditional Types مع Generics. هذا المقال هو ما كنت أتمنى أن أجده حينها — شرح عميق لكيفية عمل هذه التقنيات خلف الكواليس، وليس مجرد تعريفات جافة.
الـ Generics في TypeScript ليست مجرد أداة لكتابة كود reusable. هي طريقة للتفكير في الأنواع كقيم يمكن معالجتها في وقت الـ Compile. تخيل أنك تكتب دالة في JavaScript تأخذ أي نوع وترجع نفس النوع — مثل identity function. في TypeScript، يمكنك كتابة هذا باستخدام Generics، لكن القوة الحقيقية تظهر عندما تبدأ في تركيب هذه الأنواع مع بعضها البعض، تماماً كما تفعل مع الدوال في وقت التشغيل. الفرق هو أن كل هذا يحدث في عقل الـ Type Checker، قبل أن يصل الكود إلى المتصفح أو السيرفر.
عندما ترى <T> في كود TypeScript، قد تعتقد أنها مجرد placeholder للنوع. لكن في الواقع، الـ T هنا هو متغير نوعي — تماماً كما أن x في دالة رياضية هو متغير يمكن أن يأخذ أي قيمة. الفرق هو أن الـ T لا يقبل أي قيمة، بل أي نوع. وهذا يفتح الباب لكتابة كود لا يعتمد على نوع محدد، بل على سلوك النوع. مثلاً، مكتبة مثل React تستخدم Generics بكثافة في hooks مثل useState وuseReducer. عندما تكتب useState<number>(0)، أنت تخبر TypeScript أن الـ State سيكون من نوع number، لكن الـ Hook نفسه مصمم ليعمل مع أي نوع. هذا هو جوهر الـ Generics: كتابة كود عام لكنه آمن من حيث الأنواع.
لكن الـ Generics ليست مجرد <T>. يمكنك استخدام أسماء ذات معنى لتحسين قراءة الكود. مثلاً، في مكتبة Zod، سترى أنواع مثل z.array<T>(schema). هنا، الـ T ليس مجرد حرف عشوائي — هو تذكير بأنك تتعامل مع نوع عنصر المصفوفة. كذلك، يمكنك استخدام Generics متعددة في نفس الدالة أو النوع. مثلاً، دالة تأخذ key وvalue وتعيد كائناً من النوعين:
function createObject<K extends string, V>(key: K, value: V): { [P in K]: V } {
return { [key]: value } as { [P in K]: V };
}
const user = createObject('name', 'Ahmed'); // { name: string }
const c createObject('timeout', 5000); // { timeout: number }لاحظ كيف استخدمنا K extends string لضمان أن الـ Key سيكون دائماً string. هذا ليس مجرد تقييد — هو جزء من تصميم الـ API. إذا حاولت تمرير عدد كKey، سيشتكي TypeScript. أيضاً، لاحظ استخدام الـ Mapped Type { [P in K]: V } لإنشاء نوع الكائن الناتج. هذا مثال رائع على كيف يمكن للـ Generics أن تتفاعل مع الأنواع الأخرى لتوليد أنواع جديدة ديناميكياً.
إذا كانت الـ Generics هي المتغيرات في عالم الأنواع، فالـ Conditional Types هي الـ if statements. تخيل أنك تريد كتابة نوع يتحقق من نوع معين ويعيد نوعاً مختلفاً بناءً على الشرط. مثلاً، تريد نوعاً يعيد true إذا كان النوع هو string، وfalse إذا كان أي شيء آخر. يمكنك كتابة هذا باستخدام Conditional Types:
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // falseلكن القوة الحقيقية للـ Conditional Types تظهر عندما تجمعها مع الـ Generics. مثلاً، في مكتبة React، هناك نوع يسمى ReactNode الذي يمثل أي شيء يمكن أن يكون ابناً لعنصر React. لكن ماذا لو أردت نوعاً يعيد فقط العناصر التي يمكن أن تكون أبناء لعنصر معين؟ يمكنك كتابة نوع مثل هذا:
type OnlyChildren<T> = T extends React.ReactElement ? T : never;
function renderOnlyChildren<T>(child: OnlyChildren<T>) {
return child;
}
// يعمل
renderOnlyChildren(<div />);
// لا يعمل — TypeScript سيشتكي
renderOnlyChildren('text');
renderOnlyChildren(42);هنا، استخدمنا Conditional Type مع never لإقصاء الأنواع التي لا نريدها. الـ never في TypeScript هو نوع خاص يمثل شيئاً لا يجب أن يحدث — مثلاً، دالة لا تنتهي أبداً، أو نوع لا يجب أن يستخدم. في هذا المثال، أي نوع ليس ReactElement سيتم تحويله إلى never، مما يجعل TypeScript يشتكي إذا حاولت تمريره للدالة.
هناك سلوك غريب في الـ Conditional Types يسمى Distributive Conditional Types. عندما يكون الـ T في T extends U ? A : B هو نوع union، فإن TypeScript يطبق الشرط على كل عنصر في الـ Union بشكل منفصل. مثلاً:
type ToArray<T> = T extends any ? T[] : never;
type StrOrNumArray = ToArray<string | number>;
// string[] | number[] — ليس (string | number)[]هذا السلوك مفيد جداً في بعض الحالات، لكنه قد يكون محيراً في حالات أخرى. مثلاً، إذا كنت تريد تطبيق الشرط على الـ Union ككل وليس على كل عنصر، يمكنك استخدام trick بسيط: ضع الـ T بين قوسين:
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type StrOrNumArray = ToArrayNonDistributive<string | number>;
// (string | number)[]هذا السلوك ليس مجرد تفاصيل صغيرة — هو ما يجعل مكتبات مثل Zod وio-ts ممكنة. هذه المكتبات تعتمد على Conditional Types لتحويل الـ Schemas إلى أنواع في وقت الـ Compile. مثلاً، في Zod، عندما تكتب z.string().optional()، فإن الـ TypeScript يعرف أن النتيجة هي string | undefined بفضل Conditional Types.
الـ Mapped Types تسمح لك بإنشاء أنواع جديدة بناءً على أنواع قديمة، عن طريق تحويل كل خاصية في النوع الأصلي. مثلاً، يمكنك تحويل كل خاصية في نوع إلى optional:
type Partial<T> = {
[P in keyof T]?: T[P];
};
interface User {
name: string;
age: number;
}
type PartialUser = Partial<User>;
// { name?: string; age?: number }لكن الـ Mapped Types تصبح أقوى عندما تجمعها مع Conditional Types. مثلاً، يمكنك كتابة نوع يحتفظ فقط بالخصائص التي قيمتها من نوع معين:
type FilterProperties<T, U> = {
[P in keyof T as T[P] extends U ? P : never]: T[P];
};
interface Mixed {
name: string;
age: number;
isActive: boolean;
}
type FilterProperties<Mixed, string>;
// { name: string }هنا، استخدمنا الـ as في الـ Mapped Type لإعادة تسمية الخصائص. إذا كان نوع الخاصية لا يتطابق مع الـ U، فإن الخاصية تُحول إلى never وبالتالي تختفي من النوع الناتج. هذا النوع من التلاعب بالأنواع هو ما يجعل TypeScript أداة قوية جداً لتوليد أنواع معقدة ديناميكياً.
أحد الإضافات القوية في TypeScript هي Template Literal Types، التي تسمح لك بإنشاء أنواع جديدة بناءً على سلاسل نصية. مثلاً، يمكنك كتابة نوع يعيد كل المفاتيح الممكنة التي تبدأ بكلمة get:
type Getters<T> = `get${Capitalize<keyof T & string>}`;
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// "getName" | "getAge"هذا النوع من التلاعب بالسلاسل النصية كان مستحيلاً في الإصدارات القديمة من TypeScript. الآن، يمكنك كتابة مكتبات كاملة تعتمد على Template Literal Types، مثل مكتبات الـ Router التي تتحقق من صحة الـ Paths في وقت الـ Compile. مثلاً، مكتبة مثل TanStack Router تستخدم هذه الميزة لتوفير أنواع آمنة للـ Routes.
الـ Recursive Types هي أنواع تشير إلى نفسها. أشهر مثال هو الـ JSON type، الذي يمكن أن يحتوي على كائنات أو مصفوفات تحتوي بدورها على أنواع أخرى:
type JSON =
| string
| number
| boolean
| null
| JSON[]
| { [key: string]: JSON };
const data: JSON = {
name: "Ahmed",
age: 30,
address: {
city: "Dubai",
zip: 12345
},
hobbies: ["coding", "reading"]
};لكن الـ Recursive Types تصبح أكثر إثارة عندما تجمعها مع Conditional Types. مثلاً، يمكنك كتابة نوع يتحقق من وجود خاصية معينة في كائن متداخل:
type HasKey<T, K extends string> = K extends keyof T
? true
: { [P in keyof T]: T[P] extends object ? HasKey<T[P], K> : false }[keyof T] extends true
? true
: false;
interface Nested {
a: {
b: {
c: string;
};
};
}
type HasC = HasKey<Nested, 'c'>; // true
type HasD = HasKey<Nested, 'd'>; // falseهذا النوع يتحقق أولاً إذا كانت الـ K موجودة في الـ T. إذا لم تكن، فإنه يتحقق من كل خاصية في الـ T — إذا كانت الخاصية كائناً، فإنه يتحقق بشكل متكرر من وجود الـ K فيه. هذا النوع من التحليل المتداخل هو ما يجعل مكتبات مثل io-ts قادرة على التحقق من صحة الـ Schemas المعقدة في وقت الـ Compile.
رغم قوة هذه التقنيات، إلا أنها تأتي مع تحديات حقيقية. مثلاً، الـ Distributive Conditional Types يمكن أن تسبب مشاكل عندما لا تتوقعها. في أحد المشاريع، كنا نكتب نوعاً لتحويل الـ Union إلى Tuple، لكن بسبب السلوك التوزيعي، كانت النتيجة مختلفة عما توقعنا:
type ToTuple<T> = T extends any ? [T] : never;
type StrOrNumTuple = ToTuple<string | number>;
// [string] | [number] — ليس [string, number]الحل كان استخدام trick بسيط: وضع الـ T بين قوسين لتجنب السلوك التوزيعي. لكن المشكلة الأكبر هي أن هذه الأنواع يمكن أن تجعل الـ Type Checking بطيئاً جداً. في مشروع آخر، كنا نستخدم Recursive Types مع Conditional Types لتحليل الـ API Responses، ووجدنا أن الـ Type Checking يستغرق أكثر من دقيقة في بعض الحالات. الحل كان تقسيم الأنواع إلى أجزاء أصغر واستخدام Utility Types مثل Omit وPick بدلاً من التلاعب المباشر بالأنواع.
أيضاً، هناك مشكلة مع الـ Circular Types. مثلاً، إذا كتبت نوعاً مثل:
type A = { b: B };
type B = { a: A };فإن TypeScript سيشتكي من Circular Reference. هذا ليس خطأ في TypeScript — هو تصميم متعمد لتجنب الـ Infinite Loops في وقت الـ Compile. لكن في بعض الحالات، مثل عندما تريد كتابة نوع لـ Graph أو Tree، ستحتاج إلى هذه الأنواع. الحل هو استخدام Utility Types مثل Omit أو كتابة الأنواع بطريقة لا تسبب Circularity، مثلاً:
type Node<T> = {
value: T;
children: Node<T>[];
};
// بدلاً من
// type Node<T> = {
// value: T;
// children: Node<T>[];
// };
// التي تسبب Circularity في بعض السياقاتفي رأيي، الـ Advanced Types في TypeScript هي مثل السكاكين الحادة: قوية جداً، لكنها قد تؤذي إذا لم تستخدمها بحذر. استخدمها عندما:
لكن تجنبها عندما:
في النهاية، الـ Advanced Types ليست هدفاً في حد ذاتها — هي أداة لتحقيق هدف: كتابة كود أكثر أماناً وصيانة. إذا كانت الأنواع تجعل الكود أسهل للفهم وأقل عرضة للأخطاء، فاستخدمها. إذا كانت تجعل الكود معقداً وغير مفهوم، فابتعد عنها.
إذا أخذت شيئاً واحداً من هذا المقال، فليكن هذا: توقف عن التفكير في الأنواع كشيء ثابت. الأنواع في TypeScript هي قيم يمكن معالجتها في وقت الـ Compile، تماماً كما تعالج البيانات في وقت التشغيل. ابدأ بكتابة أنواع بسيطة، ثم استخدم الـ Generics وConditional Types وMapped Types لتوليد أنواع أكثر تعقيداً بناءً على احتياجاتك. لا تكتب أنواعاً معقدة من الصفر — اكتب أنواعاً تولد أنواعاً أخرى.
في المرة القادمة التي تواجه فيها مشكلة في الأنواع، اسأل نفسك: هل يمكنني كتابة نوع يولد هذا النوع بناءً على مدخلات معينة؟ مثلاً، بدلاً من كتابة Union Type يدوياً لكل حالة، اكتب Conditional Type يتحقق من الشرط وينشئ النوع المناسب. هذا النهج سيجعل كودك أكثر قابلية للصيانة وسيقلل من الأخطاء في وقت الـ Compile. تذكر: الهدف ليس كتابة أنواع معقدة — الهدف هو كتابة كود يعمل بشكل صحيح من المرة الأولى.