اكتشف كيف تحول TypeScript Utility Types من معاناة كتابة الأنواع المتكررة إلى متعة حقيقية، مع أمثلة عملية تكشف كيف تختصر ساعات من العمل وتجنبك أخطاء الأنواع المدمرة في المشاريع الكبيرة.
تخيل أنك تعمل على مشروع ضخم بـ TypeScript، وكلما أضفت ميزة جديدة تضطر لإعادة تعريف نفس الأنواع مرات ومرات. مثلاً، لديك واجهة User تحتوي على عشر خصائص، والآن تحتاج نسخة منها لكن بخاصية واحدة فقط قابلة للتعديل. بدلاً من كتابة واجهة جديدة بالكامل، تكتب Partial<User> وتستريح. هذه هي سحر Utility Types في TypeScript: أدوات مدمجة تحول ساعات من العمل اليدوي إلى سطر واحد، وتضمن لك أن الأنواع التي تستخدمها دقيقة وآمنة كما لو كتبتها بنفسك.
المشكلة الحقيقية ليست فقط في الوقت المهدور، بل في الأخطاء التي تنشأ من التكرار. كلما كتبت نوعاً يدوياً، زادت فرصة الخطأ البشري. مثلاً، إذا غيّرت نوع خاصية في الواجهة الأصلية ونسيت تحديث النسخ الأخرى، ستجد نفسك أمام أخطاء غريبة في وقت التشغيل. Utility Types تحل هذه المشكلة بجعل الأنواع ديناميكية ومتسقة مع المصدر الأصلي. في هذا الدليل، سنفكك كيف تعمل هذه الأدوات خلف الكواليس، ونرى كيف تستخدمها في سيناريوهات حقيقية، من التعامل مع الـ API Responses إلى إدارة الـ State في تطبيقات React.
في معظم المشاريع، ستجد نفسك بحاجة إلى نسخ من أنواع موجودة لكن ببعض الخصائص اختيارية. مثلاً، عند تحديث بيانات المستخدم، لا تريد إرسال كل الحقول، فقط الحقول التي تغيرت. Partial<T> تحول كل خصائص النوع T إلى اختيارية. لكن كيف يعمل هذا خلف الكواليس؟ TypeScript لا يعدل الكائن في الذاكرة، بل يولد نوعاً جديداً في وقت الترجمة. عندما تكتب Partial<User>، المحرك ينشئ نوعاً جديداً حيث كل خاصية في User تصبح optional، وهذا يحدث في مرحلة الـ Type Checking فقط، دون أي تأثير على الأداء في وقت التشغيل.
لنأخذ مثالاً عملياً: لديك واجهة تمثل مستخدم في قاعدة بيانات، وتحتاج دالة لتحديث جزء من بياناته. بدلاً من كتابة واجهة جديدة أو استخدام أي، يمكنك استخدام Partial<User>. لاحظ كيف أن TypeScript سيطالبك بالتعامل مع كل خاصية على أنها قد تكون غير موجودة، مما يجبرك على كتابة كود أكثر أماناً. هذا مفيد جداً في الـ PATCH Requests مثلاً، حيث لا ترسل كل البيانات.
interface User {
id: string;
name: string;
email: string;
age: number;
isActive: boolean;
}
function updateUser(id: string, updates: Partial<User>): User {
// في العالم الحقيقي، ستجلب المستخدم من قاعدة البيانات
const user: User = getUserById(id);
// ندمج التحديثات مع البيانات الأصلية
return { ...user, ...updates };
}
// الآن يمكنك إرسال جزء من البيانات فقط
updateUser("123", { name: "أحمد الجديد" }); // ✅ صحيح
updateUser("123", { age: "30" }); // ❌ خطأ: age يجب أن تكون numberعلى عكس Partial، Required<T> يفعل العكس تماماً: يجعل كل الخصائص مطلوبة. هذا مفيد جداً عندما تريد تحويل نوع اختياري إلى نوع صارم. مثلاً، عند إنشاء كائن جديد، قد تريد التأكد من أن كل الحقول موجودة قبل إرسالها للسيرفر. خلف الكواليس، Required<T> يولد نوعاً جديداً حيث كل خاصية في T تصبح مطلوبة، حتى لو كانت اختيارية في النوع الأصلي. هذا لا يغير الكائن في الذاكرة، بل يغير فقط كيف ينظر TypeScript إلى النوع في وقت الترجمة.
مثال واقعي: لديك واجهة تمثل بيانات تسجيل دخول، حيث بعض الحقول اختيارية في الواجهة الأصلية (مثل phone)، لكن عند إرسال البيانات للسيرفر، تريد التأكد من أن كل الحقول موجودة. Required<T> يجبرك على توفير كل القيم، مما يقلل الأخطاء في وقت التشغيل. لاحظ كيف أن TypeScript سيعطيك خطأ إذا حاولت إرسال كائن ناقص.
interface SignupData {
username: string;
password: string;
email: string;
phone?: string; // اختياري
}
function registerUser(data: Required<SignupData>): void {
// إرسال البيانات للسيرفر
console.log("Registering:", data);
}
// ❌ خطأ: phone مفقود
registerUser({
username: "ahmed",
password: "123456",
email: "ahmed@example.com"
});
// ✅ صحيح
registerUser({
username: "ahmed",
password: "123456",
email: "ahmed@example.com",
phone: "0123456789"
});هنا يأتي الجزء الخفي الذي يقع فيه الكثيرون: Partial و Required يعملان على مستوى واحد فقط (shallow). إذا كان لديك نوع متداخل، فلن تتأثر الخصائص الداخلية. مثلاً، إذا كان لديك واجهة تحتوي على كائن داخلي، Partial<T> لن يجعل خصائص الكائن الداخلي اختيارية. هذا يعني أنك قد تظن أن كل شيء اختياري، لكن في الواقع، الخصائص المتداخلة ستبقى كما هي. الحل؟ إما استخدام مكتبات مثل lodash للتعميق، أو كتابة Utility Type مخصص للتعامل مع الأنواع المتداخلة.
interface Address {
city: string;
country: string;
}
interface User {
id: string;
name: string;
address: Address; // كائن متداخل
}
// Partial<User> يجعل خصائص User اختيارية، لكن address.city و address.country تبقى مطلوبة
const partialUser: Partial<User> = {
address: { city: "القاهرة" } // ❌ خطأ: country مفقود
};
// الحل: كتابة DeepPartial مخصص
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
const deepPartialUser: DeepPartial<User> = {
address: { city: "القاهرة" } // ✅ صحيح
};في المشاريع الكبيرة، غالباً ما تحتاج جزءً صغيراً من نوع كبير. مثلاً، لديك واجهة Product تحتوي على 20 خاصية، لكن في صفحة معينة تحتاج فقط الاسم والسعر. بدلاً من كتابة واجهة جديدة أو استخدام أي، يمكنك استخدام Pick<T, K> لاستخراج الخصائص التي تريدها فقط. خلف الكواليس، Pick ينشئ نوعاً جديداً يحتوي فقط على الخصائص المحددة، وهذا يحدث في وقت الترجمة دون أي تأثير على الأداء. هذا مفيد جداً عند التعامل مع الـ API Responses، حيث قد ترغب في استخراج جزء من البيانات فقط لتجنب الـ Over-fetching.
مثال عملي: لديك واجهة تمثل منتج في متجر إلكتروني، وتحتاج دالة تعرض فقط الاسم والسعر في قائمة المنتجات. بدلاً من تمرير الكائن الكامل، يمكنك استخدام Pick لاستخراج الخصائص المطلوبة فقط. هذا يجعل الكود أكثر وضوحاً وأقل عرضة للأخطاء، لأن TypeScript سيضمن أن الخصائص المحددة موجودة بالفعل في النوع الأصلي.
interface Product {
id: string;
name: string;
price: number;
description: string;
category: string;
stock: number;
images: string[];
}
// نريد فقط الاسم والسعر للعرض في القائمة
function displayProduct(product: Pick<Product, "name" | "price">): string {
return `${product.name} - ${product.price} جنيه`;
}
const myProduct: Product = {
id: "1",
name: "هاتف ذكي",
price: 5000,
description: "هاتف رائع",
category: "إلكترونيات",
stock: 10,
images: ["image1.jpg"]
};
console.log(displayProduct(myProduct)); // ✅ "هاتف ذكي - 5000 جنيه"
// console.log(displayProduct({ name: "هاتف" })); // ❌ خطأ: price مفقودOmit هو العكس تماماً لـ Pick. بدلاً من اختيار الخصائص التي تريدها، تختار الخصائص التي لا تريدها. مثلاً، لديك واجهة تحتوي على معلومات حساسة مثل كلمة المرور، وتريد إرسال البيانات للسيرفر دون هذه المعلومات. Omit<T, K> ينشئ نوعاً جديداً يحذف الخصائص المحددة من النوع الأصلي. هذا مفيد جداً عند التعامل مع الـ API Endpoints التي تتطلب جزءً من البيانات فقط، أو عند إرسال البيانات للعميل حيث لا تريد كشف معلومات حساسة.
مثال واقعي: لديك واجهة تمثل بيانات المستخدم، وتحتاج إرسال هذه البيانات للعميل دون كلمة المرور و الـ Token. بدلاً من كتابة واجهة جديدة، يمكنك استخدام Omit لاستبعاد الخصائص الحساسة. هذا يضمن أن الكود أكثر أماناً وصيانة، لأن أي تغيير في الواجهة الأصلية سينعكس تلقائياً على النوع الناتج. لاحظ كيف أن TypeScript سيعطيك خطأ إذا حاولت الوصول إلى الخصائص المحذوفة.
interface UserData {
id: string;
name: string;
email: string;
password: string; // حساس
token: string; // حساس
isActive: boolean;
}
// نريد إرسال البيانات للعميل دون كلمة المرور والـ Token
function sendUserData(user: Omit<UserData, "password" | "token">): void {
console.log("Sending:", user);
}
const user: UserData = {
id: "123",
name: "أحمد",
email: "ahmed@example.com",
password: "secret123",
token: "abc123",
isActive: true
};
sendUserData(user); // ✅ صحيح: password و token غير موجودة في النوع الناتج
// console.log(user.token); // ❌ خطأ: token محذوف من النوع الناتجهذا سؤال شائع، والإجابة تعتمد على السياق. إذا كان لديك عدد قليل من الخصائص التي تريدها، استخدم Pick. إذا كان لديك عدد قليل من الخصائص التي لا تريدها، استخدم Omit. مثلاً، إذا كانت الواجهة تحتوي على 20 خاصية وتريد 18 منها، استخدم Omit لحذف الاثنتين. وإذا كانت تحتوي على 20 خاصية وتريد اثنتين فقط، استخدم Pick. القاعدة العامة: اختر الأداة التي تجعل الكود أكثر وضوحاً وقابلية للصيانة. في رأيي، Omit أكثر أماناً عند التعامل مع البيانات الحساسة، لأنه يضمن أنك لن تنسى حذف خاصية حساسة عن طريق الخطأ.
في بعض الأحيان، تحتاج إلى كائن يعمل كقاموس، حيث المفاتيح من نوع معين والقيم من نوع آخر. مثلاً، تخزين إعدادات المستخدم حيث المفاتيح هي أسماء الإعدادات والقيم هي القيم الموافقة. Record<K, T> يسمح لك بإنشاء نوع يمثل هذا القاموس بسهولة. خلف الكواليس، Record ينشئ نوعاً جديداً حيث كل مفتاح من النوع K له قيمة من النوع T. هذا مفيد جداً عند التعامل مع البيانات الديناميكية، مثل الـ Configurations أو الـ Lookup Tables.
مثال عملي: لديك تطبيق يحتاج لتخزين إعدادات المستخدم، حيث المفاتيح هي سلاسل نصية والقيم هي boolean. بدلاً من كتابة نوع مخصص، يمكنك استخدام Record<string, boolean>. هذا يجعل الكود أكثر مرونة، لأنك تستطيع إضافة أو حذف إعدادات دون تعديل النوع. لاحظ كيف أن TypeScript سيعطيك خطأ إذا حاولت تعيين قيمة غير boolean لأي مفتاح.
type UserSettings = Record<string, boolean>;
const settings: UserSettings = {
darkMode: true,
notifications: false,
autoSave: true
};
// ✅ صحيح
settings.newSetting = false;
// ❌ خطأ: القيمة يجب أن تكون boolean
settings.invalidSetting = "yes";
// مثال آخر: قاموس للمستخدمين حسب الـ ID
interface User {
id: string;
name: string;
}
const users: Record<string, User> = {
"1": { id: "1", name: "أحمد" },
"2": { id: "2", name: "محمد" }
};
console.log(users["1"]?.name); // ✅ "أحمد"في التطبيقات الكبيرة، غالباً ما تحتاج إلى حماية البيانات من التعديل العرضي. مثلاً، لديك كائن يمثل إعدادات التطبيق، ولا تريد أن يتم تعديلها بعد التحميل الأولي. Readonly<T> يجعل كل خصائص النوع T غير قابلة للتعديل. خلف الكواليس، هذا يولد نوعاً جديداً حيث كل خاصية تصبح readonly، مما يمنع أي محاولة لتعديلها في وقت الترجمة. هذا لا يغير الكائن في الذاكرة، لكنه يضمن أن TypeScript سيعطيك خطأ إذا حاولت التعديل.
مثال واقعي: لديك كائن يمثل إعدادات التطبيق، وتريد تحميلها مرة واحدة ثم منع أي تعديل لاحق. بدلاً من الاعتماد على الوثائق أو الاتفاقيات، يمكنك استخدام Readonly لضمان أن الكود لن يسمح بالتعديل. هذا مفيد جداً في الـ Configuration Objects أو الـ Constants، حيث تريد ضمان أن القيم تبقى ثابتة طوال عمر التطبيق.
interface AppConfig {
apiUrl: string;
maxRetries: number;
timeout: number;
}
const config: Readonly<AppConfig> = {
apiUrl: "https://api.example.com",
maxRetries: 3,
timeout: 5000
};
// ❌ خطأ: لا يمكن تعديل خاصية في كائن readonly
config.apiUrl = "https://new-api.example.com";
// ✅ صحيح: يمكنك قراءة القيم
console.log(config.apiUrl); // "https://api.example.com"
// مثال آخر: مصفوفة غير قابلة للتعديل
const readonlyArray: ReadonlyArray<number> = [1, 2, 3];
// readonlyArray.push(4); // ❌ خطأكثيرون يخلطون بين Readonly و const، لكنهما يفعلان أشياء مختلفة تماماً. const تمنع إعادة تعيين المتغير نفسه، لكنها لا تمنع تعديل الخصائص الداخلية للكائن. مثلاً، إذا كان لديك كائن const، يمكنك تعديل خصائصه، لكن لا يمكنك تعيين كائن جديد للمتغير. أما Readonly، فهي تحول كل خصائص الكائن إلى غير قابلة للتعديل، لكنها لا تمنع إعادة تعيين المتغير نفسه. في الواقع، يمكنك دمج الاثنين معاً للحصول على أقصى حماية: const مع Readonly تضمن أن المتغير لن يعاد تعيينه وأن خصائصه لن تتغير.
const user = {
name: "أحمد",
age: 30
};
// ✅ صحيح: const تسمح بتعديل الخصائص
user.age = 31;
// ❌ خطأ: const تمنع إعادة التعيين
// user = { name: "محمد", age: 25 };
const readonlyUser: Readonly<{ name: string; age: number }> = {
name: "أحمد",
age: 30
};
// ❌ خطأ: Readonly تمنع تعديل الخصائص
// readonlyUser.age = 31;
// ✅ صحيح: يمكنك قراءة القيم
console.log(readonlyUser.name);على الرغم من قوة Utility Types المدمجة، أحياناً تحتاج إلى شيء مخصص لمشروعك. مثلاً، قد تريد نوعاً يحول كل خصائص الكائن إلى nullable، أو نوعاً يستخرج فقط الخصائص الوظيفية من واجهة. لحسن الحظ، TypeScript يسمح لك بإنشاء Utility Types المخصصة باستخدام الـ Mapped Types و الـ Conditional Types. هذا يفتح لك عالماً من الإمكانيات، حيث يمكنك كتابة أنواع ذكية تتكيف مع احتياجات مشروعك.
مثال عملي: تريد نوعاً يجعل كل خصائص الكائن nullable. بدلاً من كتابة هذا يدوياً لكل نوع، يمكنك إنشاء Nullable<T> يستخدم Mapped Type لتحويل كل خاصية إلى اتحاد مع null. هذا مفيد جداً عند التعامل مع البيانات التي قد تكون غير موجودة، مثل الـ API Responses التي قد تحتوي على قيم null. لاحظ كيف أن هذا النوع يعمل مع أي واجهة، مما يجعله قابلاً لإعادة الاستخدام في كل المشروع.
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
interface User {
id: string;
name: string;
email: string;
}
const nullableUser: Nullable<User> = {
id: "123",
name: null,
email: "ahmed@example.com"
};
// مثال آخر: نوع يجعل كل الخصائص اختيارية وقابلة لـ null
type OptionalNullable<T> = {
[P in keyof T]?: T[P] | null;
};
const optionalNullableUser: OptionalNullable<User> = {
name: null // ✅ صحيح: اختياري وقابل لـ null
};إذا أردت كتابة Utility Types متقدمة، ستحتاج إلى فهم Conditional Types. هذه الأنواع تسمح لك بكتابة منطق شرطي في تعريفات الأنواع، مما يجعلها ديناميكية وقابلة للتكيف. مثلاً، يمكنك كتابة نوع يستخرج فقط الخصائص الوظيفية من واجهة، أو نوع يحول كل الخصائص إلى نوع معين. Conditional Types هي ما يجعل Utility Types المدمجة مثل Exclude و Extract ممكنة. في الواقع، كل Utility Type مدمج في TypeScript مبني باستخدام هذه الميزة.
// نوع يستخرج فقط الخصائص الوظيفية من واجهة
interface Example {
name: string;
age: number;
getName(): string;
setAge(age: number): void;
}
type FunctionProperties<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K]
};
type ExampleFuncti FunctionProperties<Example>;
// ينتج: { getName(): string; setAge(age: number): void }
// نوع يحول كل الخصائص إلى string
type Stringify<T> = {
[K in keyof T]: string;
};
const stringified: Stringify<Example> = {
name: "أحمد",
age: "30", // ✅ صحيح: تحول إلى string
getName: "function", // ✅ صحيح
setAge: "function" // ✅ صحيح
};حتى مع Utility Types القوية، هناك أخطاء شائعة يقع فيها المطورون. مثلاً، استخدام Partial على نوع متداخل دون التفكير في أن الخصائص الداخلية ستبقى كما هي. هذا قد يؤدي إلى أخطاء في وقت التشغيل عندما تتوقع أن الخصائص الداخلية اختيارية. الحل؟ إما استخدام DeepPartial مخصص، أو كتابة كود يتعامل مع احتمال عدم وجود الخصائص الداخلية.
مشكلة أخرى شائعة هي نسيان أن Utility Types تعمل في وقت الترجمة فقط. مثلاً، قد تستخدم Readonly على كائن ثم تحاول تعديله في وقت التشغيل، معتقداً أن TypeScript سيمنع ذلك. لكن في الواقع، TypeScript لا يضيف أي حماية في وقت التشغيل، بل يعطي خطأ في وقت الترجمة فقط. إذا كنت تريد حماية حقيقية في وقت التشغيل، ستحتاج إلى استخدام Object.freeze أو مكتبة مثل immer. أيضاً، كن حذراً عند استخدام Utility Types مع المصفوفات، لأن بعض الأنواع مثل ReadonlyArray لا تمنع تعديل العناصر الداخلية للمصفوفة.
بعد عشر سنوات في تطوير الويب، هذه هي نصائحي الذهبية لاستخدام TypeScript Utility Types بفعالية: أولاً، لا تستخدمها فقط لتوفير الوقت، بل استخدمها لجعل الكود أكثر أماناً وصيانة. مثلاً، استخدم Omit لحذف البيانات الحساسة بدلاً من الاعتماد على الوثائق. ثانياً، استثمر وقتاً في كتابة Utility Types المخصصة لمشروعك — ستوفر عليك مئات الساعات في المستقبل. ثالثاً، تذكر دائماً أن Utility Types تعمل في وقت الترجمة فقط، فلا تعتمد عليها للحماية في وقت التشغيل. وأخيراً، لا تستخدمها بشكل مفرط — أحياناً، كتابة نوع مخصص تكون أوضح وأكثر قابلية للصيانة من استخدام Utility Type معقد.
في رأيي، Utility Types هي واحدة من أقوى ميزات TypeScript، لكنها مثل أي أداة قوية، يمكن أن تستخدم بشكل صحيح أو خاطئ. استخدمها بحكمة، ودمجها مع ممارسات التطوير الجيدة، وستجد نفسك تكتب كوداً أفضل وأسرع وأكثر أماناً. وإذا كنت تعمل في مشروع كبير، خصص وقتاً لتوثيق Utility Types المخصصة التي تستخدمها، لأن هذا سيجعل حياة الفريق أسهل بكثير عندما يعودون للكود بعد أشهر.