اكتشف تقنيات TypeScript المتقدمة التي يستخدمها المحترفون لبناء أنظمة قوية ومرنة، من الـ Generics العميقة إلى Conditional Types الذكية، مع أمثلة حقيقية من مشاريع الإنتاج.
في أحد مشروعات الإنتاج الكبيرة التي عملت عليها، كان لدينا نظام إدارة محتوى معقد يتعامل مع أنواع بيانات متعددة: مقالات، فيديوهات، بودكاست، وحتى بيانات تحليلية ديناميكية. المشكلة؟ كنا نستخدم TypeScript بطريقة سطحية، مما أدى إلى تكرار هائل في الأكواد وتحديات في الصيانة. مثلاً، كانت لدينا دالة واحدة للتحقق من صحة البيانات، لكننا اضطررنا لكتابة نسخ مختلفة منها لكل نوع بيانات. هنا أدركت أن الوقت قد حان للانتقال من TypeScript الأساسي إلى عالم الـ Advanced Types. ليس فقط لتحسين الكود، بل لتجنب الكوارث التي تحدث عندما يتوسع المشروع.
الـ Generics ليست مجرد أداة لإضافة مرونة للكود، بل هي طريقة تفكير. عندما تستخدمها بشكل صحيح، تصبح قادراً على كتابة أكواد تعمل مع أي نوع بيانات دون التضحية بالـ Type Safety. لكن المشكلة الحقيقية تبدأ عندما تظن أنك فهمت الـ Generics لأنك استخدمت `Array<T>` أو `Promise<T>`. الحقيقة هي أن الـ Generics الحقيقية تبدأ عندما تتعمق في كيفية عملها خلف الكواليس، وكيف تتفاعل مع الـ Type Inference والـ Type Constraints.
لنبدأ بمثال بسيط لكن عملي. تخيل أنك تبني مكتبة لإدارة الـ State في تطبيق React، وتريد إنشاء دالة `createStore` تتعامل مع أي نوع بيانات دون معرفة مسبقة به. الحل التقليدي هو استخدام `any`، لكن هذا يفقدك كل مزايا TypeScript. بدلاً من ذلك، يمكنك استخدام الـ Generics لكتابة دالة تقبل نوعاً عاماً وتعيده بنفس النوع:
function createStore<T>(initialState: T): { getState: () => T; setState: (newState: T) => void } {
let state = initialState;
return {
getState: () => state,
setState: (newState: T) => {
state = newState;
}
};
}
const numberStore = createStore(0);
const stringStore = createStore("hello");
// numberStore.setState("text"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.هذا المثال يبدو بسيطاً، لكنه يوضح نقطة مهمة: الـ Generics تسمح لك بكتابة كود مرن وآمن في نفس الوقت. لكن ماذا لو أردنا إضافة قيد على النوع `T`؟ مثلاً، نريد أن نضمن أن الـ State يحتوي على خاصية `id`. هنا يأتي دور الـ Type Constraints باستخدام الكلمة المفتاحية `extends`:
interface Identifiable {
id: string;
}
function createIdentifiableStore<T extends Identifiable>(initialState: T) {
// ... نفس الكود السابق
}
// createIdentifiableStore({ name: "test" }); // Error: Property 'id' is missing.
createIdentifiableStore({ id: "1", name: "test" }); // Worksالآن، دعنا نتعمق أكثر. ماذا لو أردنا إنشاء دالة تقبل نوعين مختلفين وتعيد نوعاً جديداً يجمع بينهما؟ هذا هو المكان الذي تصبح فيه الـ Generics قوية حقاً. لنفترض أننا نعمل على نظام إدارة مستخدمين ونريد دمج بيانات المستخدم مع بيانات إضافية ديناميكياً:
function mergeData<T, U>(base: T, extension: U): T & U {
return { ...base, ...extension };
}
const user = { id: "1", name: "Ahmed" };
const additi { age: 30, isAdmin: true };
const merged = mergeData(user, additionalData);
// merged is of type { id: string; name: string; age: number; isAdmin: boolean }هنا تكمن قوة الـ Generics الحقيقية. لاحظ كيف أن TypeScript يفهم أن النتيجة هي دمج النوعين `T` و `U` دون الحاجة إلى أي تدخل يدوي. لكن هذا مجرد بداية. في مشاريع الإنتاج، غالباً ما نحتاج إلى التعامل مع أنواع أكثر تعقيداً، مثل المصفوفات أو الكائنات المتداخلة. مثلاً، إذا أردنا كتابة دالة تأخذ مصفوفة من الكائنات وتعيد مصفوفة تحتوي على خاصية معينة فقط:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(item => item[key]);
}
const users = [
{ id: "1", name: "Ahmed" },
{ id: "2", name: "Sara" }
];
const names = pluck(users, "name"); // string[]
const ids = pluck(users, "id"); // string[]هذا النوع من الدوال شائع جداً في المكتبات مثل Lodash أو Ramda، وهو يوضح كيف يمكن للـ Generics أن تجعل الكود أكثر قابلية لإعادة الاستخدام دون التضحية بالـ Type Safety. لكن هناك مشكلة شائعة هنا: ماذا لو حاولت استخدام مفتاح غير موجود في النوع `T`؟ TypeScript سيعطيك خطأ في وقت التطوير، وهذا بالضبط ما نريده. لكن في بعض الأحيان، نحتاج إلى مرونة أكبر، وهذا يقودنا إلى موضوع الـ Conditional Types.
الـ Conditional Types هي واحدة من أقوى ميزات TypeScript، وهي تسمح لك بكتابة منطق داخل نظام الأنواع نفسه. فكر فيها كجمل `if-else` لكنها تعمل على مستوى الأنواع. الصيغة الأساسية هي `T extends U ? X : Y`، والتي تعني: إذا كان النوع `T` قابلاً للتعيين للنوع `U`، استخدم النوع `X`، وإلا استخدم النوع `Y`.
لنبدأ بمثال بسيط لكن قوي. تخيل أنك تريد إنشاء نوع جديد يستخرج نوع القيمة من Promise. يمكنك كتابة:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<string>; // stringالكلمة المفتاحية `infer` هنا هي المفتاح. إنها تسمح لـ TypeScript باستنتاج النوع داخل الـ Promise دون الحاجة إلى تحديده يدوياً. هذا النوع من الأنواع يستخدم بكثرة في مكتبات مثل Redux أو Apollo Client للتعامل مع الـ Async Data. لكن دعنا نرى مثالاً أكثر تعقيداً. لنفترض أننا نعمل على نظام معالجة بيانات ونريد إنشاء نوع يتحقق من وجود خاصية معينة في الكائن:
type HasProperty<T, K extends string> = K extends keyof T ? true : false;
type User = { id: string; name: string };
type HasId = HasProperty<User, "id">; // true
type HasAge = HasProperty<User, "age">; // falseهذا النوع بسيط، لكنه يوضح كيف يمكن للـ Conditional Types أن تضيف منطقاً إلى نظام الأنواع. لكن القوة الحقيقية تأتي عندما تجمع بين الـ Conditional Types والـ Mapped Types أو الـ Recursive Types. مثلاً، لنفترض أننا نريد إنشاء نوع يستخرج جميع الخصائص الاختيارية من كائن:
type OptionalKeys<T> = {
[K in keyof T]: T extends { [P in K]: T[P] } ? never : K
}[keyof T];
type User = {
id: string;
name?: string;
age?: number;
};
type Opti OptionalKeys<User>; // "name" | "age"هذا النوع يعمل عن طريق المرور على كل مفتاح في الكائن `T` والتحقق مما إذا كان هذا المفتاح اختيارياً. إذا كان اختيارياً، نحتفظ بالمفتاح، وإلا نضع `never`. ثم نستخدم `[keyof T]` لاستخراج جميع المفاتيح التي لم تكن `never`. هذا النوع من الأنواع يستخدم في مكتبات مثل Formik أو React Hook Form للتعامل مع الـ Form Validation.
لكن هناك مشكلة شائعة هنا: ماذا لو كان الكائن يحتوي على خصائص متداخلة؟ مثلاً، إذا كان لدينا كائن يحتوي على كائنات أخرى، ونريد استخراج جميع الخصائص الاختيارية المتداخلة؟ هنا نحتاج إلى نوع متكرر:
type DeepOptionalKeys<T> = {
[K in keyof T]: T[K] extends object
? DeepOptionalKeys<T[K]> | K
: T extends { [P in K]: T[P] }
? never
: K;
}[keyof T];
type ComplexUser = {
id: string;
profile?: {
name?: string;
age?: number;
address?: {
city?: string;
country: string;
};
};
};
type DeepOpti DeepOptionalKeys<ComplexUser>; // "profile" | "name" | "age" | "address" | "city"هذا النوع أكثر تعقيداً، لكنه يوضح كيف يمكن للـ Conditional Types أن تتعامل مع الهياكل المتداخلة. لاحظ كيف نستخدم `T[K] extends object` للتحقق مما إذا كانت الخاصية كائناً، وإذا كانت كذلك، نطبق النوع `DeepOptionalKeys` بشكل متكرر. هذه التقنية تستخدم في مكتبات مثل TypeORM أو Prisma للتعامل مع الـ Database Models.
هناك جانب مهم من الـ Conditional Types لا يتحدث عنه الكثيرون: الـ Distributive Nature. عندما تستخدم Conditional Type مع نوع union، فإن TypeScript يطبق الشرط على كل عنصر في الـ Union بشكل منفصل. مثلاً:
type ToArray<T> = T extends any ? T[] : never;
type StrOrNum = string | number;
type StrOrNumArray = ToArray<StrOrNum>; // string[] | number[]في هذا المثال، بدلاً من الحصول على `(string | number)[]`، نحصل على `string[] | number[]`. هذا السلوك يسمى الـ Distributive Conditional Types، وهو مفيد جداً في العديد من السيناريوهات. مثلاً، إذا أردنا إنشاء نوع يستخرج جميع الأنواع البدائية من Union:
type ExtractPrimitives<T> = T extends string | number | boolean | symbol | bigint | null | undefined ? T : never;
type Mixed = string | number | { id: string } | boolean;
type Primitives ExtractPrimitives<Mixed>; // string | number | booleanهذا النوع يزيل جميع الأنواع غير البدائية من الـ Union. لكن ماذا لو أردنا العكس؟ مثلاً، استخراج جميع الأنواع غير البدائية؟ يمكننا استخدام نفس المبدأ:
type ExtractNonPrimitives<T> = T extends string | number | boolean | symbol | bigint | null | undefined ? never : T;
type N ExtractNonPrimitives<Mixed>; // { id: string }هذا السلوك مفيد جداً في مكتبات مثل Zod أو Yup للتعامل مع الـ Schema Validation. مثلاً، يمكنك إنشاء نوع يتحقق من أن جميع الخصائص في كائن هي أنواع بدائية:
type AllPrimitives<T> = {
[K in keyof T]: T[K] extends string | number | boolean | symbol | bigint | null | undefined ? T[K] : never;
};
type ValidC AllPrimitives<{ name: string; age: number }>; // OK
type InvalidConfig = AllPrimitives<{ name: string; data: { id: string } }>; // Error: Type '{ id: string; }' is not assignable to type 'never'.لكن هناك فخ شائع هنا: إذا كنت تريد تجنب السلوك التوزيعي، يمكنك لف النوع بـ Tuple. مثلاً:
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type N ToArrayNonDistributive<StrOrNum>; // (string | number)[]الـ Mapped Types تسمح لك بإنشاء أنواع جديدة بناءً على أنواع موجودة، وهي أداة قوية جداً عندما تريد تحويل كائن إلى نوع آخر. مثلاً، يمكنك إنشاء نوع يجعل جميع خصائص الكائن اختيارية:
type Partial<T> = {
[K in keyof T]?: T[K];
};
type User = { id: string; name: string };
type PartialUser = Partial<User>; // { id?: string; name?: string }هذا النوع موجود بالفعل في مكتبة TypeScript القياسية، لكنه يوضح كيف يمكن للـ Mapped Types أن تحول الكود. لكن القوة الحقيقية تأتي عندما تجمع بين الـ Mapped Types والـ Conditional Types. مثلاً، يمكنك إنشاء نوع يجعل جميع الخصائص التي تبدأ بكلمة معينة اختيارية:
type OptionalIfStartsWith<T, Prefix extends string> = {
[K in keyof T as K extends `${Prefix}${string}` ? K : never]?: T[K];
} & {
[K in keyof T as K extends `${Prefix}${string}` ? never : K]: T[K];
};
type C {
apiUrl: string;
apiKey: string;
timeout: number;
retryCount: number;
};
type OptionalApiConfig = OptionalIfStartsWith<Config, "api">;
// { apiUrl?: string; apiKey?: string; timeout: number; retryCount: number }هنا استخدمنا الـ Template Literal Types مع الـ Mapped Types لإنشاء نوع جديد. لاحظ كيف استخدمنا `as` لإعادة تسمية المفاتيح بناءً على شرط. هذه التقنية تستخدم في مكتبات مثل Axios أو Fetch للتعامل مع الـ API Configurations.
لكن هناك مشكلة شائعة هنا: ماذا لو أردنا إنشاء نوع ديناميكي بناءً على قيمة؟ مثلاً، إذا كان لدينا كائن يحتوي على أنواع مختلفة بناءً على قيمة معينة؟ هنا يأتي دور الـ Template Literal Types مع الـ Conditional Types:
type EventMap = {
click: { x: number; y: number };
keypress: { key: string };
};
type EventType = keyof EventMap;
type EventPayload<T extends EventType> = T extends EventType ? EventMap[T] : never;
function handleEvent<T extends EventType>(type: T, payload: EventPayload<T>) {
// ...
}
handleEvent("click", { x: 10, y: 20 }); // OK
handleEvent("keypress", { key: "Enter" }); // OK
handleEvent("click", { key: "Enter" }); // Error: Type '{ key: string; }' is not assignable to type '{ x: number; y: number; }'.هذا النمط شائع جداً في مكتبات مثل React أو Vue للتعامل مع الـ Event Handling. لاحظ كيف أن TypeScript يفهم العلاقة بين نوع الحدث والبيانات المرتبطة به دون الحاجة إلى أي تدخل يدوي.
الأنواع المتكررة هي واحدة من أقوى ميزات TypeScript، لكنها غالباً ما تكون مخيفة للمطورين الجدد. الفكرة بسيطة: نوع يمكن أن يحتوي على نفسه. مثلاً، نوع يمثل شجرة:
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
const tree: TreeNode<number> = {
value: 1,
children: [
{ value: 2, children: [] },
{
value: 3,
children: [
{ value: 4, children: [] }
]
}
]
};هذا النوع مفيد جداً في الهياكل المتداخلة مثل الـ JSON Responses أو الـ File Systems. لكن القوة الحقيقية تأتي عندما تجمع بين الـ Recursive Types والـ Conditional Types. مثلاً، يمكنك إنشاء نوع يتحقق من أن جميع القيم في شجرة هي أنواع بدائية:
type AllPrimitivesInTree<T> = T extends TreeNode<infer U>
? U extends string | number | boolean | symbol | bigint | null | undefined
? true
: false
: false;
type ValidTree = AllPrimitivesInTree<TreeNode<number>>; // true
type InvalidTree = AllPrimitivesInTree<TreeNode<{ id: string }>>; // falseهذا النوع يتحقق من أن جميع القيم في الشجرة هي أنواع بدائية. لكن ماذا لو أردنا التحقق من أن جميع القيم في الشجرة هي من نوع معين؟ يمكننا تعديل النوع ليصبح أكثر عمومية:
type AllValuesOfType<T, U> = T extends TreeNode<infer V>
? V extends U
? true
: false
: false;
type AllNumbers = AllValuesOfType<TreeNode<number>, number>; // true
type NotAllNumbers = AllValuesOfType<TreeNode<number | string>, number>; // falseهذا النوع مفيد جداً في مكتبات مثل D3.js أو Vis.js للتعامل مع الـ Data Visualization. لكن هناك مشكلة شائعة هنا: ماذا لو أردنا إنشاء نوع متكرر مع شروط أكثر تعقيداً؟ مثلاً، نوع يتحقق من أن جميع العقد في الشجرة تحتوي على خاصية معينة؟
type TreeNodeWithId<T> = {
id: string;
value: T;
children: TreeNodeWithId<T>[];
};
type AllNodesHaveId<T> = T extends TreeNodeWithId<infer U>
? true
: false;
type ValidTreeWithId = AllNodesHaveId<TreeNodeWithId<number>>; // trueهذا النوع يتحقق من أن جميع العقد في الشجرة تحتوي على خاصية `id`. لكن لاحظ أن هذا النوع بسيط جداً ولا يتحقق من الهياكل المتداخلة بشكل صحيح. لحل هذه المشكلة، نحتاج إلى نوع متكرر أكثر تعقيداً:
type DeepCheckId<T> = T extends { id: string; children: infer C }
? C extends Array<infer U>
? DeepCheckId<U>
: true
: false;
type ValidDeepCheck = DeepCheckId<TreeNodeWithId<number>>; // trueهذا النوع يتحقق من أن جميع العقد في الشجرة تحتوي على خاصية `id` بشكل متكرر. هذه التقنية تستخدم في مكتبات مثل React Router أو Vue Router للتعامل مع الـ Nested Routes.
على الرغم من قوة الـ Advanced Types في TypeScript، إلا أنها تأتي مع تحدياتها الخاصة، خاصة فيما يتعلق بالأداء. عندما تستخدم أنواعاً معقدة، قد تلاحظ أن الـ Type Checking يصبح بطيئاً، خاصة في المشاريع الكبيرة. السبب الرئيسي هو أن TypeScript يحتاج إلى حل أنواع معقدة بشكل متكرر، وهذا يمكن أن يؤثر على وقت البناء.
إليك بعض الفخاخ الشائعة وكيفية تجنبها:
هناك أيضاً مشكلة شائعة تتعلق بالـ Type Inference. أحياناً، قد لا يستنتج TypeScript النوع الذي تريده بالضبط، خاصة مع الأنواع المعقدة. في هذه الحالات، قد تحتاج إلى استخدام الـ Type Assertions أو إعادة هيكلة الكود لجعل الأنواع أكثر وضوحاً. مثلاً:
// Instead of this (TypeScript may not infer correctly)
const result = someComplexFunction(data);
// Do this (explicit type annotation)
const result: ExpectedType = someComplexFunction(data);أخيراً، تذكر أن الـ Advanced Types هي أداة قوية، لكنها ليست دائماً الحل الأفضل. إذا وجدت نفسك تكتب أنواعاً معقدة جداً لدرجة أنها تصبح غير قابلة للفهم، فقد حان الوقت لإعادة التفكير في التصميم. أحياناً، الحل الأبسط هو الأفضل.
بعد سنوات من العمل مع TypeScript في مشاريع الإنتاج، تعلمت أن الـ Advanced Types ليست مجرد ميزة جميلة، بل هي أداة ضرورية لبناء أنظمة قوية ومرنة. إليك بعض النصائح العملية لاستخدامها بفعالية:
في النهاية، الـ Advanced Types في TypeScript هي أداة قوية تمكنك من كتابة كود أكثر أماناً ومرونة. لكنها ليست حلاً سحرياً لكل المشاكل. استخدمها بحكمة، وركز دائماً على قابلية قراءة الكود وصيانته. إذا استخدمت هذه التقنيات بشكل صحيح، ستجد نفسك تبني أنظمة أكثر قوة مع وقت أقل للتصحيح والصيانة.
الخطوة التالية؟ ابدأ بتطبيق هذه التقنيات في مشروعك الحالي. اختر دالة أو مكوناً واحداً، وحاول إعادة كتابته باستخدام الـ Generics أو الـ Conditional Types. سترى الفرق فوراً في مرونة الكود وسلامته.