عندما يتحول الكود إلى متاهة من Any وUnknown، تصبح Advanced Types في TypeScript سلاحك السري. اكتشف كيف تحول Generic Types وConditional Types من نظريات أكاديمية إلى حلول عملية تنقذ مشروعك من الكوارث النوعية.
في أحد المشاريع الكبيرة التي عملت عليها مع فريق من ١٢ مطوراً، كنا نستخدم TypeScript لبناء نظام إدارة محتوى معقد. بعد ثلاثة أشهر من التطوير، بدأنا نلاحظ شيئاً مرعباً: كلما أضفنا ميزة جديدة، كان الكود ينهار في أماكن غير متوقعة. السبب؟ كنا نعتمد على Any وUnion Types البسيطة لتجنب الأخطاء، لكن الحقيقة هي أننا كنا نكتب JavaScript متخفياً في زي TypeScript. المشكلة الحقيقية لم تكن في الأدوات، بل في أننا لم نكن نعرف كيف نستخدم Advanced Types بشكل صحيح. اليوم، سأريك كيف تحولنا من فريق يخاف من أنواع البيانات المعقدة إلى فريق يستغل Generic وConditional Types لكتابة كود آمن وقابل للصيانة.
الـ Generic Types ليست مجرد ميزة جميلة تضاف للكود، بل هي الأداة التي تحول TypeScript من لغة تكتب فيها أنواعاً ثابتة إلى لغة تفكر فيها بأنواع ديناميكية. تخيل أنك تبني دالة تأخذ مصفوفة من أي نوع وتعيد مصفوفة من نفس النوع، لكن مع تعديل بسيط. بدون Generic، ستضطر إلى كتابة دالة لكل نوع على حدة، أو الأسوأ، ستستخدم Any وتخسر كل فوائد TypeScript. لكن مع Generic، يمكنك كتابة دالة واحدة تعمل مع أي نوع، وتحافظ على سلامة الأنواع في نفس الوقت. هذا ليس مجرد توفير في الكود، بل هو توفير في الوقت والجهد والدموع عندما يأتي وقت الـ Debugging.
لنبدأ بمثال بسيط لكنه قوي. تخيل أنك تعمل على مكتبة للتعامل مع البيانات من APIs مختلفة، وكل API يعيد بيانات بتنسيق مختلف. بدلاً من كتابة دالة لكل نوع من البيانات، يمكنك استخدام Generic Type لإنشاء دالة واحدة تتعامل مع أي نوع. لكن هنا تكمن المشكلة: الكثير من المطورين يستخدمون Generic بشكل سطحي، دون فهم كيف تعمل خلف الكواليس. عندما تكتب دالة مثل function identity<T>(arg: T): T، فإن TypeScript لا ينشئ نوعاً جديداً في الذاكرة لكل نوع T، بل يستخدم نظاماً ذكياً يسمى Structural Typing. هذا يعني أن TypeScript يتحقق من شكل النوع وليس اسمه، مما يجعل النظام مرناً للغاية.
لكن المرونة لها ثمنها. إذا استخدمت Generic Types بدون قيود، قد ينتهي بك الأمر بأنواع غامضة يصعب تتبعها. مثلاً، إذا كتبت دالة تأخذ Generic Type بدون أي قيود، فقد تجد نفسك في موقف حيث لا تعرف ما إذا كان النوع يحتوي على خاصية معينة أم لا. هنا يأتي دور Constraints. باستخدام extends، يمكنك تقييد Generic Type ليقبل فقط أنواعاً معينة. مثلاً، function getLength<T extends { length: number }>(arg: T): number ستقبل فقط أنواعاً تحتوي على خاصية length من نوع number. هذا ليس مجرد تحسين للكود، بل هو حماية لك من الأخطاء التي قد تظهر في وقت التشغيل.
// مثال على Generic Type مع Constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
interface User {
id: number;
name: string;
email: string;
}
const user: User = { id: 1, name: "Ahmed", email: "ahmed@example.com" };
const userName = getProperty(user, "name"); // string
// const invalid = getProperty(user, "age"); // Error: Argument of type '"age"' is not assignable to parameter of type 'keyof User'.
// مثال على Generic مع Default Type
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
const stringArray = createArray(3, "hello"); // string[]
const numberArray = createArray<number>(3, 42); // number[]الـ Generic Types ليست مقتصرة على الدوال فقط. يمكنك استخدامها في الواجهات والكلاسات لجعلها أكثر مرونة وقابلية لإعادة الاستخدام. تخيل أنك تبني نظاماً لإدارة المخازن، وتحتاج إلى واجهة تمثل منتجاً يمكن أن يكون له أنواع مختلفة من البيانات الإضافية. بدلاً من كتابة واجهة لكل نوع من المنتجات، يمكنك استخدام Generic Interface. مثلاً، interface Product<T> { id: number; name: string; details: T; } يمكن استخدامها لإنشاء منتجات بأنواع مختلفة من التفاصيل دون تكرار الكود.
لكن هنا تأتي المشكلة الحقيقية: عندما تستخدم Generic في الكلاسات، قد تواجه تحدياً في التعامل مع الوراثة. مثلاً، إذا كان لديك كلاس عام مثل class Repository<T>، وقد ترغب في إنشاء كلاس فرعي يتعامل مع نوع محدد مثل class UserRepository extends Repository<User>. في هذه الحالة، يجب أن تتأكد من أن الكلاس الفرعي يحترم القيود التي وضعتها في الكلاس العام. إذا لم تفعل ذلك، فقد تواجه أخطاء في وقت التشغيل يصعب تتبعها. الحل؟ استخدم Constraints في الكلاس العام لتقييد الأنواع التي يمكن استخدامها، وتأكد من أن الكلاسات الفرعية تلتزم بهذه القيود.
// Generic Interface
interface ApiResponse<T> {
status: number;
data: T;
message?: string;
}
interface UserData {
id: number;
username: string;
}
const userResponse: ApiResponse<UserData> = {
status: 200,
data: { id: 1, username: "dev_ahmed" },
};
// Generic Class
class Repository<T extends { id: number }> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
}
class UserRepository extends Repository<User> {
findByUsername(username: string): User | undefined {
return this.items.find(user => user.username === username);
}
}إذا كانت Generic Types هي الأداة التي تجعل الأنواع ديناميكية، فإن Conditional Types هي الأداة التي تجعل الأنواع ذكية. تخيل أنك تريد كتابة نوع يتغير بناءً على نوع آخر، مثل أن تقول: "إذا كان النوع هو string، فاستخدم number، وإلا فاستخدم boolean". هذا بالضبط ما تفعله Conditional Types. لكن هنا تكمن المشكلة: الكثير من المطورين يستخدمون Conditional Types بدون فهم عميق لكيفية عملها، مما يؤدي إلى أنواع معقدة يصعب فهمها أو صيانتها.
لنأخذ مثالاً عملياً. تخيل أنك تعمل على مكتبة للتعامل مع الأحداث، وتحتاج إلى نوع يمثل نتيجة حدث معين. إذا كان الحدث ناجحاً، فستعيد بيانات معينة، وإذا فشل، فستعيد رسالة خطأ. بدلاً من كتابة نوعين منفصلين، يمكنك استخدام Conditional Type لإنشاء نوع واحد يتغير بناءً على نوع الحدث. لكن هنا يأتي التحدي: Conditional Types تعمل في وقت التحويل (compile time)، وليس في وقت التشغيل. هذا يعني أنك لا تستطيع استخدامها لاتخاذ قرارات ديناميكية بناءً على قيم البيانات، بل فقط بناءً على أنواع البيانات. إذا حاولت فعل ذلك، ستواجه أخطاء في وقت التحويل يصعب فهمها.
// Conditional Type بسيط
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
// Conditional Type مع Infer
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type C = UnwrapPromise<Promise<string>>; // string
type D = UnwrapPromise<number>; // number
// مثال عملي: Result Type
interface SuccessResult<T> {
success: true;
data: T;
}
interface ErrorResult {
success: false;
error: string;
}
type Result<T> = T extends Error ? ErrorResult : SuccessResult<T>;
function handleResult<T>(result: Result<T>): void {
if (result.success) {
console.log("Data:", result.data);
} else {
console.error("Error:", result.error);
}
}
// استخدام مع Generic
function fetchData<T>(url: string): Promise<Result<T>> {
return fetch(url)
.then(resp> response.json())
.then(data => ({ success: true, data }))
.catch(error => ({ success: false, error: error.message }));
}عندما تجمع بين Conditional Types وMapped Types، تحصل على أداة قوية جداً لتحويل الأنواع بشكل ديناميكي. تخيل أنك تريد إنشاء نوع جديد بناءً على نوع موجود، لكن مع تغيير بعض الخصائص. مثلاً، يمكنك تحويل كل الخصائص في واجهة معينة إلى اختيارية، أو تحويل أنواع الخصائص من string إلى number. هذا بالضبط ما تفعله Mapped Types مع Conditional Types. لكن هنا تكمن المشكلة: إذا لم تكن حذراً، قد ينتهي بك الأمر بأنواع معقدة جداً يصعب فهمها أو تعديلها لاحقاً.
لنأخذ مثالاً من العالم الحقيقي. في أحد المشاريع التي عملت عليها، كنا نستخدم GraphQL للحصول على البيانات من الـ Backend. المشكلة كانت في أن الـ Schema في GraphQL يختلف عن الـ Types في TypeScript، وكنا بحاجة إلى تحويل الأنواع تلقائياً. باستخدام Mapped Types مع Conditional Types، تمكنا من كتابة نوع واحد يحول الـ GraphQL Schema إلى TypeScript Types بشكل ديناميكي. هذا وفر علينا ساعات من العمل اليدوي، وأزال الكثير من الأخطاء المحتملة. لكن يجب أن تكون حذراً: كلما زاد تعقيد الأنواع، زاد الوقت الذي يستغرقه TypeScript لتحويل الكود، مما قد يؤثر على أداء الـ Build.
// Mapped Type مع Conditional Type
interface User {
id: number;
name: string;
age: number;
email: string;
}
// تحويل جميع الخصائص إلى اختيارية إلا id
type PartialExceptId<T, K extends keyof T> = {
[P in keyof T]?: P extends K ? T[P] : T[P] | undefined;
} & { [P in K]: T[P] };
type UserPartial = PartialExceptId<User, "id">;
// النتيجة: { id: number; name?: string; age?: number; email?: string }
// تحويل أنواع الخصائص بناءً على شرط
type StringToNumber<T> = {
[K in keyof T]: T[K] extends string ? number : T[K];
};
type UserNumbers = StringToNumber<User>;
// النتيجة: { id: number; name: number; age: number; email: number }
// مثال عملي: تحويل GraphQL Schema إلى TypeScript Types
type GraphQLType = "String" | "Int" | "Float" | "Boolean" | "ID";
type GraphQLField = {
type: GraphQLType;
required?: boolean;
};
interface GraphQLSchema {
[key: string]: GraphQLField;
}
type GraphQLToTS<T extends GraphQLSchema> = {
[K in keyof T]: T[K]["type"] extends "String"
? string
: T[K]["type"] extends "Int" | "Float"
? number
: T[K]["type"] extends "Boolean"
? boolean
: T[K]["type"] extends "ID"
? string | number
: never;
};
const userSchema = {
id: { type: "ID", required: true },
name: { type: "String", required: true },
age: { type: "Int" },
isActive: { type: "Boolean" },
};
type UserTS = GraphQLToTS<typeof userSchema>;
// النتيجة: { id: string | number; name: string; age: number; isActive: boolean }عندما تبدأ في استخدام Advanced Types في TypeScript، ستواجه بعض الأخطاء التي قد تبدو محيرة في البداية. أحد أكثر الأخطاء شيوعاً هو استخدام Generic Types بدون قيود، مما يؤدي إلى أنواع غامضة يصعب تتبعها. مثلاً، إذا كتبت دالة تأخذ Generic Type بدون أي قيود، فقد ينتهي بك الأمر بأنواع غير متوقعة في وقت التحويل. الحل؟ دائماً استخدم Constraints لتحديد أنواع البيانات التي يمكن استخدامها مع Generic Type.
خطأ آخر شائع هو استخدام Conditional Types بطريقة تؤدي إلى أنواع معقدة جداً. مثلاً، قد تكتب Conditional Type يتفرع إلى عدة مستويات، مما يجعل الكود صعب الفهم والصيانة. الحل؟ حاول تبسيط الأنواع قدر الإمكان، واستخدم الأسماء الوصفية للأنواع لجعل الكود أكثر وضوحاً. إذا وجدت نفسك تكتب Conditional Type معقدة جداً، فكر في تقسيمها إلى أنواع أصغر وأكثر وضوحاً.
على الرغم من قوة Advanced Types في TypeScript، هناك حالات قد يكون من الأفضل تجنبها. مثلاً، إذا كنت تعمل على مشروع صغير أو مع فريق غير متمرس في TypeScript، فقد يكون من الأفضل استخدام أنواع بسيطة لتجنب التعقيد غير الضروري. أيضاً، إذا كنت تعمل على كود سيتغير كثيراً في المستقبل، فقد يكون من الأفضل تجنب الأنواع المعقدة التي قد تحتاج إلى تعديل مستمر. في النهاية، الهدف من TypeScript هو جعل الكود أكثر أماناً وأسهل صيانة، وليس أكثر تعقيداً.
أيضاً، إذا كنت تعمل على مشروع يتطلب أداء عالياً في وقت التحويل، فقد تحتاج إلى تجنب الأنواع المعقدة التي تزيد من وقت التحويل. مثلاً، إذا كنت تستخدم Conditional Types معقدة جداً، فقد تلاحظ أن وقت التحويل يزداد بشكل ملحوظ. في هذه الحالة، قد يكون من الأفضل تبسيط الأنواع أو استخدام أنواع ثابتة بدلاً من الأنواع الديناميكية.
بعد سنوات من العمل مع TypeScript في مشاريع مختلفة، تعلمت أن Advanced Types ليست مجرد ميزة جميلة، بل هي أداة قوية يمكن أن تنقذ مشروعك من الكوارث النوعية. لكن مثل أي أداة قوية، يجب استخدامها بحذر. ابدأ دائماً بأنواع بسيطة، ثم أضف التعقيد تدريجياً فقط عندما تحتاج إليه. ولا تنسَ أن الهدف النهائي هو كتابة كود آمن وسهل الصيانة، وليس كوداً معقداً يثير إعجاب الآخرين.
نصيحة أخيرة: إذا وجدت نفسك تكتب نوعاً معقداً جداً، توقف واسأل نفسك: هل هذا ضروري حقاً؟ في كثير من الأحيان، يمكن حل المشكلة بأنواع أبسط وأكثر وضوحاً. وعندما تستخدم Advanced Types، تأكد من أنك تفهم تماماً كيف تعمل خلف الكواليس، وليس فقط كيف تكتبها. ، ستتمكن من الاستفادة الكاملة من قوة TypeScript دون الوقوع في فخ التعقيد غير الضروري.
TypeScript ليست مجرد أداة لكتابة أنواع ثابتة، بل هي لغة برمجة كاملة داخل لغة برمجة. إذا لم تستغل قوتها الكاملة، فأنت تخسر الكثير.
— مهندس برمجيات سنيور في شركة تقنية كبرى