اكتشف كيف تحول Generic وConditional Types في TypeScript الكود العادي إلى آلة ذكية تتوقع الأخطاء قبل كتابتها، وتحول المطورين إلى سحرة البيانات. هذا ليس مقالاً عن الأساسيات، بل عن التقنيات التي يستخدمها المحترفون لبناء مكتبات مثل React وVue.
تخيل أنك تعمل على مكتبة مكونات ضخمة مثل Material UI أو Ant Design، وكلما أضفت مكوناً جديداً، تضطر لكتابة نفس الأنواع (Types) مراراً وتكراراً. مثلاً، مكون Button يحتاج نوع Props، ومكون Table يحتاج نوع Props آخر، وكلاهما يشتركان في بعض الخصائص مثل disabled وonClick. هنا تبدأ الكارثة: بدلاً من كتابة 100 سطر من الأنواع المكررة، تجد نفسك أمام خيارين: إما أن تترك الكود يتضخم ويصبح صعب الصيانة، أو تستخدم Generic Types لتكتب نوعاً واحداً يتعامل مع جميع الحالات. لكن المشكلة الأكبر هي أن Generic Types وحدها لا تكفي عندما تريد أن تجعل النوع يتغير بناءً على مدخلات معينة، مثل أن يكون نوع خرج الدالة مختلفاً بناءً على نوع دخلها. هنا يأتي دور Conditional Types، التي تسمح لك بكتابة أنواع تتكيف ديناميكياً مثل الكود نفسه.
في هذا المقال، لن نتحدث عن الأساسيات التي تجدها في كل دورة تعليمية. سنتجاوز ذلك إلى المستوى الذي يستخدمه مطورو مكتبات TypeScript الكبيرة. سنبدأ بفهم كيف تعمل Generic Types خلف الكواليس في الذاكرة، وكيف يمكن استخدامها لبناء أنواع ديناميكية تتفاعل مع البيانات الحقيقية. ثم سننتقل إلى Conditional Types، التي تسمح لك بكتابة أنواع تتخذ قرارات بناءً على شروط، تماماً كما يفعل الكود العادي. وأخيراً، سنجمع كل ذلك في مثال عملي يوضح كيف يمكن استخدام هذه التقنيات لبناء مكتبة مكونات ذكية تتكيف مع أي نوع بيانات تلقائياً.
عندما بدأت استخدام TypeScript لأول مرة، كنت أرى Generic Types كشيء معقد لا داعي له. لماذا أكتب <T> بدلاً من كتابة النوع مباشرة؟ لكن عندما عملت على مشروع يتطلب مكتبة مكونات قابلة لإعادة الاستخدام، أدركت أن Generic Types ليست مجرد ميزة تجميلية، بل هي أداة قوية لتجنب تكرار الكود وتحسين الأداء. فكر في Generic Type كمتغير للنوع نفسه. بدلاً من كتابة دالة تأخذ نوعاً محدداً مثل string أو number، يمكنك كتابة دالة تأخذ نوعاً عاماً T، ثم تحدد هذا النوع عند استخدام الدالة. هذا يعني أنك تكتب الكود مرة واحدة، لكنه يعمل مع أي نوع بيانات.
لكن كيف يعمل هذا خلف الكواليس؟ عندما تكتب دالة generic مثل function identity<T>(arg: T): T، فإن TypeScript لا يولد نسخة جديدة من الدالة لكل نوع بيانات. بدلاً من ذلك، يستخدم ما يسمى بـ Type Erasure، حيث يتم إزالة الأنواع في وقت الترجمة (compile time) ويبقى الكود النهائي كجافا سكريبت عادي. هذا يعني أن الأداء لا يتأثر، لكنك تحصل على أمان الأنواع في وقت التطوير. المشكلة الحقيقية تبدأ عندما تريد استخدام Generic Types مع أنواع معقدة، مثل الكائنات أو المصفوفات. مثلاً، إذا كتبت دالة تأخذ مصفوفة من نوع T وترجع مصفوفة من نفس النوع، فأنت بحاجة إلى فهم كيف يتعامل TypeScript مع الأنواع المركبة.
// مثال بسيط على Generic Function
function identity<T>(arg: T): T {
return arg;
}
// استخدام الدالة مع أنواع مختلفة
const num = identity<number>(42); // number
const str = identity<string>('hello'); // string
// مثال مع أنواع مركبة
function reverseArray<T>(items: T[]): T[] {
return items.reverse();
}
const numbers = reverseArray([1, 2, 3]); // number[]
const strings = reverseArray(['a', 'b', 'c']); // string[]
// مثال متقدم: Generic مع الكائنات
interface ApiResponse<T> {
data: T;
status: number;
message?: string;
}
const userResponse: ApiResponse<{ id: number; name: string }> = {
data: { id: 1, name: 'Ahmed' },
status: 200,
};
// مثال مع Default Generic Type
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
const strArray = createArray(3, 'x'); // string[]
const numArray = createArray<number>(3, 0); // number[]في أحد المشاريع التي عملت عليها، استخدم فريق التطوير Generic Types بشكل مفرط دون فهم حقيقي لكيفية عملها. كانت النتيجة كارثية: أنواع معقدة لا يمكن تتبعها، أخطاء في وقت التشغيل لأن الأنواع لم تكن متوافقة، وأداء متراجع لأن TypeScript كان يحاول استنتاج أنواع ضخمة ومعقدة. المشكلة الرئيسية كانت في استخدام Generic Types مع أنواع متداخلة بشكل مفرط. مثلاً، بدلاً من كتابة نوع بسيط مثل Array<User>، كتبوا Array<Array<Partial<Record<string, User>>>>. هذا النوع ليس فقط صعب القراءة، بل يجعل TypeScript يعمل بجد لاستنتاج الأنواع في كل مرة يستخدم فيها الكود، مما يؤدي إلى بطء في عملية البناء (build time).
الحل؟ استخدم Generic Types فقط عندما تحتاج فعلاً إلى مرونة في الأنواع. إذا كان النوع ثابتاً ولا يتغير، فلا داعي لاستخدام Generic. مثلاً، إذا كنت تكتب دالة تأخذ مصفوفة من الأرقام وترجع مصفوفة من الأرقام، فلا حاجة لكتابة function processArray<T>(arr: T[]): T[]. بدلاً من ذلك، اكتب function processArray(arr: number[]): number[]. هذا يجعل الكود أسهل قراءة وأسرع في التنفيذ. أيضاً، تجنب استخدام Generic Types مع أنواع غير ضرورية، مثل استخدام T مع أنواع بدائية مثل string أو number، إلا إذا كنت بحاجة فعلاً إلى المرونة.
إذا كانت Generic Types هي المتغيرات للنوع، فإن Conditional Types هي الجمل الشرطية. تخيل أنك تريد كتابة دالة تأخذ نوعاً معيناً، وترجع نوعاً مختلفاً بناءً على نوع الدخل. مثلاً، إذا كان الدخل من نوع string، ترجع boolean، وإذا كان من نوع number، ترجع string. بدون Conditional Types، ستضطر لكتابة عدة دوال متداخلة أو استخدام any، مما يفقدك أمان الأنواع. لكن مع Conditional Types، يمكنك كتابة نوع واحد يتكيف مع أي حالة.
السر وراء Conditional Types هو أنها تعمل في وقت الترجمة فقط، ولا تؤثر على الأداء في وقت التشغيل. عندما تكتب نوعاً مثل T extends U ? X : Y، فإن TypeScript يقوم بتقييم هذا الشرط في وقت الترجمة ويستنتج النوع النهائي بناءً على نوع T. هذا يعني أنك تحصل على أمان الأنواع دون أي تكلفة في الأداء. لكن المشكلة الحقيقية تبدأ عندما تتداخل Conditional Types مع بعضها البعض، مما يؤدي إلى أنواع معقدة يصعب تتبعها. مثلاً، في مكتبة React، يستخدم Conditional Types لتحديد ما إذا كان المكون يقبل children أم لا، بناءً على نوع Props الذي يمرره المستخدم.
// مثال بسيط على Conditional Type
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
// مثال متقدم: استخراج نوع من كائن
interface User {
id: number;
name: string;
age?: number;
}
type ExtractPropertyType<T, K extends keyof T> = T[K] extends infer U
? U extends number
? 'number'
: U extends string
? 'string'
: 'unknown'
: never;
type UserIdType = ExtractPropertyType<User, 'id'>; // 'number'
type UserNameType = ExtractPropertyType<User, 'name'>; // 'string'
// مثال من مكتبة React: تحديد ما إذا كان المكون يقبل children
interface PropsWithChildren<P = {}> extends P {
children?: React.ReactNode;
}
type ComponentProps<T> = T extends React.ComponentType<infer P>
? P extends PropsWithChildren<infer P>
? P & { children?: React.ReactNode }
: P
: never;
const Button: React.FC<{ onClick: () => void }> = ({ onClick }) => {
return <button {onClick}>Click</button>;
};
type ButtonProps = ComponentProps<typeof Button>; // { onClick: () => void } & { children?: React.ReactNode | undefined }في أحد المشاريع الكبيرة، استخدم فريق التطوير Conditional Types لبناء نظام أنواع ديناميكي للمكونات. كانت الفكرة جيدة: كتابة نوع واحد يتكيف مع أي مكون بناءً على Props الذي يمرره المستخدم. لكن المشكلة بدأت عندما استخدموا Conditional Types مع أنواع متداخلة بشكل مفرط. مثلاً، كتبوا نوعاً مثل هذا: type GetComponentProps<T> = T extends React.FC<infer P> ? P extends { children?: infer C } ? C : never : never. هذا النوع ليس فقط صعب القراءة، بل يجعل TypeScript يعمل بجد لاستنتاج الأنواع، مما يؤدي إلى أخطاء غامضة في وقت الترجمة.
الحل؟ تجنب التداخل المفرط في Conditional Types. إذا وجدت نفسك تكتب نوعاً مع أكثر من ثلاثة مستويات من التداخل، فهذا يعني أنك بحاجة إلى إعادة التفكير في التصميم. بدلاً من ذلك، قسم النوع إلى أنواع أصغر وأسهل قراءة. مثلاً، يمكنك كتابة نوعين منفصلين: type ExtractProps<T> = T extends React.FC<infer P> ? P : never; و type ExtractChildren<P> = P extends { children?: infer C } ? C : never. هذا يجعل الكود أسهل قراءة وصيانة، ويقلل من احتمالية الأخطاء. أيضاً، تجنب استخدام infer مع أنواع معقدة، لأن ذلك قد يؤدي إلى استنتاجات غير متوقعة.
الفرق بين Generic Types وConditional Types ليس واضحاً دائماً، خاصة للمطورين الجدد. Generic Types هي أداة لإضفاء المرونة على الأنواع، بينما Conditional Types هي أداة لجعل الأنواع تتخذ قرارات بناءً على شروط. استخدم Generic Types عندما تريد كتابة كود يعمل مع أنواع مختلفة دون تكرار، مثل الدوال أو المكونات التي تتعامل مع أنواع متعددة. مثلاً، إذا كنت تكتب مكتبة مكونات، يمكنك استخدام Generic Types لجعل المكونات تعمل مع أي نوع بيانات يمرره المستخدم.
من ناحية أخرى، استخدم Conditional Types عندما تريد أن يتغير النوع بناءً على شرط معين. مثلاً، إذا كنت تكتب مكتبة للتعامل مع البيانات، ويمكن أن تكون البيانات من نوع string أو number، وتريد أن يتغير نوع الخرج بناءً على نوع الدخل، فإن Conditional Types هي الأداة المناسبة. أيضاً، استخدم Conditional Types عندما تريد استخراج أنواع معينة من أنواع معقدة، مثل استخراج نوع خاصية معينة من كائن أو تحديد ما إذا كان النوع يقبل children أم لا. لكن تذكر: كلما زاد تعقيد الأنواع، زاد الوقت الذي يستغرقه TypeScript لاستنتاجها، مما يؤثر على أداء عملية البناء.
لنفترض أنك تريد بناء مكتبة مكونات ذكية تتكيف مع أي نوع بيانات يمرره المستخدم. مثلاً، مكون Table يمكنه عرض أي نوع بيانات، سواء كانت قائمة مستخدمين أو قائمة منتجات. بدون Advanced Types، ستضطر لكتابة عدة مكونات مختلفة لكل نوع بيانات، مما يؤدي إلى تكرار الكود وصعوبة الصيانة. لكن باستخدام Generic وConditional Types، يمكنك كتابة مكون واحد يتكيف مع أي نوع بيانات تلقائياً.
في هذا المثال، سنكتب مكون Table يقبل نوعاً عاماً T، ويستخدم Conditional Types لتحديد ما إذا كان العمود يقبل نوعاً معيناً أم لا. مثلاً، إذا كان العمود من نوع number، سنعرضه بتنسيق معين، وإذا كان من نوع string، سنعرضه بتنسيق مختلف. أيضاً، سنستخدم Generic Types لجعل المكون يعمل مع أي نوع بيانات يمرره المستخدم. هذا يجعل المكتبة مرنة وقابلة لإعادة الاستخدام، دون التضحية بأمان الأنواع.
// تعريف أنواع العمود باستخدام Conditional Types
interface Column<T> {
key: keyof T;
title: string;
render?: (value: T[keyof T], record: T) => React.ReactNode;
align?: 'left' | 'center' | 'right';
format?: (value: T[keyof T]) => string;
}
// تحديد نوع تنسيق العمود بناءً على نوع البيانات
type ColumnFormat<T, K extends keyof T> = T[K] extends number
? { format?: (value: number) => string }
: T[K] extends string
? { format?: (value: string) => string }
: { format?: (value: any) => string };
// مكون Table باستخدام Generic Types
interface TableProps<T> {
data: T[];
columns: (Column<T> & ColumnFormat<T, keyof T>)[];
rowKey: keyof T;
}
function Table<T>({ data, columns, rowKey }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((column) => (
<th key={String(column.key)} style={{ textAlign: column.align || 'left' }}>
{column.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((record) => (
<tr key={String(record[rowKey])}>
{columns.map((column) => (
<td key={String(column.key)} style={{ textAlign: column.align || 'left' }}>
{column.render
? column.render(record[column.key], record)
: column.format
? column.format(record[column.key])
: String(record[column.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// مثال على استخدام المكون
interface User {
id: number;
name: string;
age: number;
email: string;
}
const users: User[] = [
{ id: 1, name: 'Ahmed', age: 30, email: 'ahmed@example.com' },
{ id: 2, name: 'Sara', age: 25, email: 'sara@example.com' },
];
const userColumns: (Column<User> & ColumnFormat<User, keyof User>)[] = [
{ key: 'id', title: 'ID', align: 'center' },
{ key: 'name', title: 'Name' },
{
key: 'age',
title: 'Age',
align: 'right',
format: (value) => `${value} years`,
},
{
key: 'email',
title: 'Email',
render: (value) => <a href={`mailto:${value}`}>{value}</a>,
},
];
// استخدام المكون
const App = () => {
return <Table<User> data={users} columns={userColumns} rowKey="id" />;
};إذا كنت تريد أن تصبح محترفاً في TypeScript، فلا تتوقف عند الأساسيات. تعلم كيف تستخدم Generic وConditional Types لبناء كود مرن وقابل لإعادة الاستخدام. لكن تذكر: مع القوة تأتي المسؤولية. كلما زاد تعقيد الأنواع، زاد الوقت الذي يستغرقه TypeScript لاستنتاجها، وزادت احتمالية الأخطاء. لذلك، استخدم هذه التقنيات بحكمة، وقسم الأنواع المعقدة إلى أنواع أصغر وأسهل قراءة. أيضاً، اختبر الأنواع المعقدة باستخدام أمثلة واقعية للتأكد من أنها تعمل كما تتوقع. وأخيراً، لا تنسَ أن الهدف من TypeScript هو جعل الكود أسهل قراءة وصيانة، وليس أكثر تعقيداً.
ابدأ اليوم بتطبيق هذه التقنيات في مشروعك التالي. جرب كتابة مكونات ذكية تتكيف مع أي نوع بيانات، أو مكتبة للتعامل مع البيانات تستخدم Conditional Types لتحديد نوع الخرج بناءً على نوع الدخل. وكلما واجهت مشكلة، تذكر أن TypeScript هو أداة قوية، لكنها تحتاج إلى فهم عميق لاستخدامها بشكل صحيح.