اكتشف كيف تحول Utility Types في TypeScript من ساعات من كتابة الأنواع المكررة إلى بضعة أسطر ذكية، مع أمثلة حقيقية من مشاريع الإنتاج وأخطاء شائعة يجب تجنبها.
تخيل أنك تعمل على مشروع ضخم يستخدم GraphQL، وكل مرة يأتي فيها نوع جديد من الـ API تضطر لكتابة Interface من الصفر. مثلاً، نوع User يأتي بـ ١٥ حقل، لكن في الصفحة الرئيسية تحتاج فقط لـ name و avatar. بدلاً من كتابة interface MiniUser { name: string; avatar: string }، يمكنك ببساطة كتابة type MiniUser = Pick<User, 'name' | 'avatar'>. هذه السطر الواحد يوفر عليك كتابة ١٤ سطر من الكود المتكرر، ويضمن أن أي تعديل على الـ User سينعكس تلقائياً على الـ MiniUser. هذه هي قوة Utility Types في TypeScript - ليست مجرد اختصارات، بل أدوات ذكية تقلل الـ Boilerplate وتزيد من قابلية الصيانة.
الحقيقة هي أن معظم المطورين يستخدمون Utility Types بشكل سطحي، مثل Pick و Omit، دون فهم كيف تعمل خلف الكواليس. عندما تستخدم Pick<User, 'name'>، فإن TypeScript لا ينشئ نوع جديد في الذاكرة، بل يولد ما يسمى بـ Mapped Type داخلياً. هذا يعني أن الـ Compiler يقوم بإنشاء نوع مؤقت أثناء الـ Compile Time فقط، دون أي تأثير على أداء الـ Runtime. هذه النقطة مهمة جداً لأن الكثيرين يعتقدون أن استخدام Utility Types سيجعل التطبيق أبطأ، بينما الحقيقة هي أنها تعمل على مستوى الـ Type System فقط، تماماً مثل أي نوع آخر في TypeScript.
في عالم الـ Forms، غالباً ما نحتاج إلى نوعين مختلفين لنفس الكائن: نوع كامل للحالة النهائية، ونوع جزئي للحالة الأولية أو أثناء الـ Editing. مثلاً، في نموذج تسجيل المستخدم، قد تكون الحقول name و email إلزامية، لكن avatar اختياري. لكن أثناء عملية الـ Validation، نحتاج إلى نوع مؤقت يكون فيه جميع الحقول اختيارية. هنا يأتي دور Partial<T> الذي يحول كل خصائص النوع T إلى optional. لكن ماذا لو أردنا العكس؟ مثلاً، في حالة الـ Admin الذي يمكنه تعديل جميع الحقول بما في ذلك الحساسة مثل isVerified؟ هنا نستخدم Required<T> الذي يجعل كل الخصائص إلزامية حتى لو كانت optional في النوع الأصلي.
من تجربتي في العمل على منصة SaaS كبيرة، وجدنا أن استخدام Partial مع الـ State Management يقلل من الأخطاء المتعلقة بالحقول المفقودة بنسبة ٤٠٪. المشكلة الشائعة هي أن المطورين ينسون أن Partial لا يعمل بشكل متكرر على الـ Nested Objects. مثلاً، إذا كان لديك نوع User به خاصية address من نوع Address، فإن Partial<User> سيجعل address اختيارية، لكن خصائص Address نفسها ستبقى كما هي. لحل هذه المشكلة، نحتاج إلى كتابة Utility Type مخصص مثل DeepPartial<T> الذي يطبق Partial بشكل متكرر على جميع الـ Nested Objects.
type DeepPartial<T> = T extends object ? {
[P in keyof T]?: DeepPartial<T[P]>;
} : T;
interface Address {
city: string;
country: string;
}
interface User {
name: string;
address: Address;
}
// بدون DeepPartial
const partialUser: Partial<User> = { address: {} }; // Error: city و country مطلوبة
// مع DeepPartial
const deepPartialUser: DeepPartial<User> = { address: { city: 'Riyadh' } }; // Works perfectlyفي مشاريع الإنتاج الكبيرة، غالباً ما نواجه مشكلة الـ Duplicate Types. مثلاً، في نظام إدارة المحتوى، قد يكون لدينا نوع Article كامل، ونوع آخر للـ Preview، ونوع ثالث للـ Card. بدلاً من كتابة ثلاثة أنواع منفصلة، يمكننا استخدام Pick و Omit لاستخراج ما نحتاجه من النوع الأساسي. لكن هنا تكمن المشكلة: ماذا لو تغير النوع الأساسي؟ هل ستتأثر جميع الأنواع المشتقة؟ نعم، وهذا بالضبط ما نريده - التغيير في مكان واحد يؤثر على كل شيء، وهذا يقلل من فرص الـ Inconsistency.
في أحد المشاريع التي عملت عليها، استخدمنا Omit لإزالة الحقول الحساسة من الـ API Responses. مثلاً، كان لدينا نوع User يحتوي على password و token، وكنا نستخدم Omit<User, 'password' | 'token'> لكل الـ Responses. لكن المشكلة ظهرت عندما أضفنا حقل جديد حساس مثل apiKey - نسينا إضافته إلى قائمة الـ Omit في بعض الأماكن. الحل كان كتابة Utility Type مخصص يسمى SafeUser الذي يستخدم Omit داخلياً، وهكذا نضمن أن أي حقل جديد يضاف إلى User لن يظهر في الـ Responses إلا إذا أضفناه صراحةً إلى SafeUser.
type SafeUser = Omit<User, 'password' | 'token' | 'apiKey' | 'refreshToken'>;
// بدلاً من تكرار Omit في كل مكان
function getUser(): SafeUser {
const user: User = await fetchUser();
return user; // TypeScript سيتأكد أن الحقول الحساسة غير موجودة
}
// مشكلة شائعة: استخدام Pick بدلاً من Omit
const unsafeUser: Pick<User, 'name' | 'email'> = user; // قد ينسى المطور بعض الحقول
const safeUser: SafeUser = user; // أفضل لأنه يضمن إزالة جميع الحقول الحساسةقد يعتقد البعض أن Pick أسرع من Omit أو العكس، لكن الحقيقة هي أن كلا الأداتين لهما نفس الأداء لأنهما يعملان على مستوى الـ Type System. الفرق الرئيسي هو في قابلية القراءة والصيانة. Pick أكثر وضوحاً عندما تريد اختيار عدد قليل من الخصائص، بينما Omit أفضل عندما تريد إزالة عدد قليل من الخصائص من نوع كبير. مثلاً، إذا كان لديك نوع به ٢٠ خاصية وتريد إزالة ٢ فقط، فإن Omit سيكون أسهل في الكتابة والصيانة من Pick الذي يتطلب كتابة ١٨ خاصية.
في التطبيقات الحقيقية، غالباً ما نتعامل مع بيانات ديناميكية لا نعرف مفاتيحها مسبقاً. مثلاً، في نظام الترجمة، قد يكون لدينا كائن يحتوي على مفاتيح تمثل لغات مختلفة، والقيم تمثل الترجمات. هنا يأتي دور Record<K, T> الذي يسمح لنا بتعريف نوع للكائنات ذات المفاتيح الديناميكية. لكن Record ليس مجرد اختصار لكتابة {[key: string]: T}، بل هو أداة قوية تضمن أن جميع المفاتيح من نوع معين، وأن جميع القيم من نوع آخر.
المشكلة الشائعة مع Record هي أنه لا يسمح بمفاتيح غير معروفة مسبقاً. مثلاً، إذا أردت كائن يحتوي على مفاتيح تمثل IDs للمستخدمين، والقيم تمثل بيانات المستخدم، فإن Record<string, User> سيعمل، لكن لن يمكنك الوصول إلى المفاتيح باستخدام userId: string لأن TypeScript لن يعرف أن userId موجود في الكائن. الحل هو استخدام Map بدلاً من الكائن العادي، أو كتابة Utility Type مخصص يسمح بالمفاتيح الديناميكية مع الحفاظ على أمان الأنواع.
// مشكلة مع Record
const translations: Record<string, string> = {
en: 'Hello',
ar: 'مرحبا'
};
// لا يمكن الوصول إلى المفاتيح بشكل آمن
function getTranslation(lang: string): string {
return translations[lang]; // Error: lang قد لا تكون موجودة في الكائن
}
// حل باستخدام Map
const translati new Map<string, string>([
['en', 'Hello'],
['ar', 'مرحبا']
]);
function getTranslationSafe(lang: string): string {
const translation = translationsMap.get(lang);
if (!translation) throw new Error('Language not found');
return translation;
}
// حل آخر باستخدام Utility Type
interface Translations {
[key: string]: string;
}
type SafeTranslations<T extends string> = Record<T, string>;
const safeTranslations: SafeTranslations<'en' | 'ar'> = {
en: 'Hello',
ar: 'مرحبا'
};
function getSafeTranslation(lang: 'en' | 'ar'): string {
return safeTranslations[lang]; // آمن تماماً
}في التطبيقات الكبيرة، غالباً ما نتعامل مع كائنات ثابتة مثل الـ Configurations أو الـ Enums. المشكلة هي أن TypeScript يسمح بتعديل هذه الكائنات بشكل افتراضي، مما قد يؤدي إلى أخطاء صعبة التتبع. مثلاً، إذا كان لديك كائن CONFIG يحتوي على إعدادات التطبيق، وقد قام مطور آخر بتعديل خاصية مثل apiUrl عن طريق الخطأ، فقد يستغرق الأمر ساعات لمعرفة سبب فشل الـ API Calls. هنا يأتي دور Readonly<T> الذي يجعل جميع خصائص الكائن غير قابلة للتعديل.
لكن Readonly له حدود - فهو لا يعمل بشكل متكرر على الـ Nested Objects. مثلاً، إذا كان لديك كائن CONFIG يحتوي على كائن آخر مثل endpoints، فإن Readonly<CONFIG> سيجعل endpoints غير قابل للتعديل، لكن خصائص endpoints نفسها ستبقى قابلة للتعديل. الحل هو استخدام Const Assertions مع as const، أو كتابة Utility Type مخصص مثل DeepReadonly<T>. في أحد المشاريع، استخدمنا DeepReadonly لجميع الـ Configurations، مما قلل من الأخطاء المتعلقة بالتعديلات غير المقصودة بنسبة ٦٠٪.
type DeepReadonly<T> = T extends (infer R)[] ? ReadonlyArray<DeepReadonly<R>> :
T extends Function ? T :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
interface Endpoints {
users: string;
posts: string;
}
interface Config {
apiUrl: string;
endpoints: Endpoints;
}
// بدون DeepReadonly
const config: Readonly<Config> = {
apiUrl: 'https://api.example.com',
endpoints: {
users: '/users',
posts: '/posts'
}
};
config.endpoints.users = '/new-users'; // لا خطأ هنا! لأن Readonly لا يعمل بشكل متكرر
// مع DeepReadonly
const deepReadonlyConfig: DeepReadonly<Config> = {
apiUrl: 'https://api.example.com',
endpoints: {
users: '/users',
posts: '/posts'
}
};
deepReadonlyConfig.endpoints.users = '/new-users'; // Error: لا يمكن التعديل
// مع Const Assertions
const c {
apiUrl: 'https://api.example.com',
endpoints: {
users: '/users',
posts: '/posts'
}
} as const;
constConfig.endpoints.users = '/new-users'; // Error: لا يمكن التعديلفي كثير من الأحيان، نحتاج إلى فلترة أنواع معينة من Union Types. مثلاً، قد يكون لدينا نوع Status يمثل حالات مختلفة للطلب، ونريد إنشاء نوع جديد يحتوي فقط على الحالات النهائية مثل 'completed' و 'failed'. هنا يأتي دور Exclude<T, U> الذي يزيل جميع الأنواع الموجودة في U من T. والعكس صحيح مع Extract<T, U> الذي يحتفظ فقط بالأنواع الموجودة في U.
المشكلة الشائعة هي أن المطورين يستخدمون Exclude بشكل خاطئ مع القيم بدلاً من الأنواع. مثلاً، إذا كان لديك Union Type مثل type Status = 'pending' | 'processing' | 'completed' | 'failed'، وقد أردت إزالة 'pending' و 'processing'، فإن Exclude<Status, 'pending' | 'processing'> سيعمل بشكل صحيح. لكن إذا حاولت استخدامه مع قيم ديناميكية مثل Exclude<Status, someVariable>، فلن يعمل لأن someVariable يجب أن يكون نوعاً ثابتاً معروفاً في وقت الـ Compile، وليس قيمة متغيرة.
type Status = 'pending' | 'processing' | 'completed' | 'failed';
type FinalStatus = Exclude<Status, 'pending' | 'processing'>; // 'completed' | 'failed'
type ProcessingStatus = Extract<Status, 'pending' | 'processing'>; // 'pending' | 'processing'
// مثال عملي من مشروع حقيقي
interface Order {
id: string;
status: Status;
items: string[];
}
// دالة ترسل إشعار فقط للطلبات النهائية
function sendNotification(order: Omit<Order, 'status'> & { status: FinalStatus }) {
// إرسال الإشعار
}
// خطأ شائع: استخدام Exclude مع قيم متغيرة
function getFinalStatus(status: Status): FinalStatus {
return Exclude<Status, 'pending' | 'processing'>; // Error: لا يمكن استخدام Exclude هنا
// الحل الصحيح:
return status === 'pending' || status === 'processing' ? 'completed' : status;
}على الرغم من قوة Utility Types المدمجة في TypeScript، إلا أنها لا تغطي جميع الحالات التي قد تواجهها في المشاريع الحقيقية. مثلاً، قد تحتاج إلى نوع يزيل جميع الخصائص الاختيارية من الكائن، أو نوع يجعل جميع الخصائص قابلة لـ Null، أو نوع يستخرج جميع الخصائص من نوع معين التي تكون من نوع Function. في هذه الحالات، يمكنك كتابة Utility Types مخصصة باستخدام مزيج من Mapped Types و Conditional Types.
في أحد المشاريع الكبيرة الذي عملت عليه، كنا نستخدم GraphQL مع Apollo Client، وكنا بحاجة إلى نوع يحول جميع الخصائص من نوع Maybe<T> إلى T، لأن Apollo يعيد القيم كـ Maybe<T> (أي T | null | undefined). بدلاً من كتابة هذا النوع يدوياً لكل استجابة، كتبنا Utility Type يسمى UnwrapMaybe<T> الذي يزيل Maybe من جميع الخصائص بشكل متكرر. هذا النوع وفر علينا ساعات من كتابة الأنواع اليدوية وجعل الكود أكثر أماناً.
type Maybe<T> = T | null | undefined;
type UnwrapMaybe<T> = T extends Maybe<infer U> ? U : T;
type DeepUnwrapMaybe<T> = T extends object ? {
[K in keyof T]: DeepUnwrapMaybe<UnwrapMaybe<T[K]>>;
} : UnwrapMaybe<T>;
interface UserResponse {
user: Maybe<{
id: string;
name: Maybe<string>;
address: Maybe<{
city: Maybe<string>;
country: Maybe<string>;
}>;
}>;
}
// بدون DeepUnwrapMaybe
const userResponse: UserResp await fetchUser();
const name = userResponse.user?.name; // string | null | undefined
// مع DeepUnwrapMaybe
const unwrappedUser: DeepUnwrapMaybe<UserResponse> = await fetchUser();
const safeName = unwrappedUser.user.name; // string - لا حاجة لـ Optional ChainingConditional Types هي أقوى ميزة في نظام أنواع TypeScript، وهي التي تسمح لك بكتابة Utility Types معقدة. مثلاً، يمكنك كتابة نوع يستخرج جميع الخصائص من نوع معين التي تكون من نوع معين. في المثال التالي، سنكتب نوعاً يسمى FunctionProperties<T> الذي يستخرج جميع الخصائص من T التي تكون من نوع Function.
type FunctionProperties<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type NonFunctionProperties<T> = {
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
interface Example {
name: string;
age: number;
greet: () => void;
calculate: (x: number) => number;
}
type Functi FunctionProperties<Example>; // 'greet' | 'calculate'
type NonFunctions = NonFunctionProperties<Example>; // 'name' | 'age'
// استخدام عملي: فصل الـ Methods عن الـ Properties
function bindMethods<T extends object>(obj: T): Omit<T, FunctionProperties<T>> {
const result = { ...obj };
(Object.keys(obj) as Array<keyof T>).forEach(key => {
if (typeof obj[key] === 'function') {
result[key] = obj[key].bind(obj);
}
});
return result as Omit<T, FunctionProperties<T>>;
}على الرغم من فائدة Utility Types، إلا أن هناك أخطاء شائعة يقع فيها المطورون، خاصة المبتدئين. الخطأ الأول هو استخدام Utility Types بشكل مفرط حتى في الحالات البسيطة. مثلاً، كتابة type Name = Pick<User, 'name'> بدلاً من string مباشرةً، وهذا يجعل الكود أكثر تعقيداً دون داعٍ. القاعدة الذهبية هي: استخدم Utility Types عندما توفر قيمة حقيقية، وليس لمجرد استخدامها.
الخطأ الثاني هو تجاهل أداء Utility Types المعقدة. على الرغم من أن Utility Types لا تؤثر على أداء الـ Runtime، إلا أنها قد تؤثر على أداء الـ Compiler، خاصة إذا كانت معقدة جداً أو تستخدم بشكل متكرر في مشروع كبير. مثلاً، استخدام DeepReadonly أو DeepPartial بشكل متكرر في مشروع به آلاف الأنواع قد يجعل وقت الـ Compilation أطول. الحل هو استخدام هذه الأنواع فقط عندما تكون ضرورية، وتجنب استخدامها في الـ Hot Paths.
Utility Types في TypeScript ليست مجرد ميزة جميلة، بل هي أداة قوية تزيد من إنتاجيتك وتقلل من الأخطاء. القاعدة الأولى هي: ابدأ باستخدام الأدوات المدمجة مثل Pick و Omit و Partial قبل كتابة أي شيء مخصص. غالباً ما ستجد أن هذه الأدوات تغطي ٨٠٪ من احتياجاتك. عندما تحتاج إلى شيء مخصص، ابدأ بكتابة النوع يدوياً، ثم حاول تحويله إلى Utility Type قابل لإعادة الاستخدام.
النصيحة الذهبية هي: استخدم Utility Types لخلق طبقة تجريدية بين أنواع البيانات الأساسية والأنواع المستخدمة في منطق التطبيق. مثلاً، بدلاً من استخدام نوع User مباشرةً في كل مكان، استخدم أنواعاً مشتقة مثل PublicUser و AdminUser و EditableUser. هذا النهج يجعل الكود أكثر مرونة، ويسهل إجراء التغييرات في المستقبل دون كسر التطبيق. وأخيراً، تذكر أن الهدف من Utility Types هو جعل الكود أكثر أماناً وقابلية للصيانة، وليس أكثر تعقيداً - إذا وجدت نفسك تكتب أنواعاً معقدة جداً، ربما تحتاج إلى إعادة التفكير في التصميم.