الوحدة 3 · البرمجة على مستوى الأنواع
الـ Mapped Types وإعادة تسمية المفاتيح
19 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
حوّل كل خصائص النوع دفعة واحدة بالـ mapped types، وأضف معدِّلات readonly والاختيارية أو أزلها، وأعِد تسمية المفاتيح أو احذفها بجمل as، معيدًا بناء Partial وPick وOmit في الطريق.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
في صفحة الدفع في Cartwheel نموذج للعنوان. نوع العنوان بسيط:
type Address = { line1: string; city: string; country: string; postcode?: string };لكن النموذج يحتاج إلى أكثر من العنوان. يحتاج لكل حقل إلى قيمته الحالية، وخطأ التحقّق، وما إذا كان المستخدم قد لمسه. ويحتاج الـ endpoint الخاص بـ PATCH إلى نسخة كل حقولها اختيارية. وتحتاج شاشة المراجعة إلى نسخة مجمّدة. يمكنك كتابة ثلاثة أنواع أخرى يدويًا، كلٌّ منها يكرّر أسماء الحقول الأربعة، وتحديثها كلها كلما تغيّر Address. أو يمكنك كتابة كلٍّ منها مرة واحدة، كتحويل. هذا هو الـ mapped type (النوع المحوَّل): حلقة تكرار على المفاتيح، على مستوى الأنواع.
الحلقة
type FieldState<T> = {
[K in keyof T]: { value: T[K]; error: string | null; touched: boolean };
};
type AddressForm = FieldState<Address>;
// {
// line1: { value: string; error: string | null; touched: boolean };
// city: { value: string; … };
// country: { value: string; … };
// postcode?: { value: string | undefined; … };
// }اقرأ [K in keyof T] هكذا: “لكل مفتاح K من T”. يُقيَّم الطرف الأيمن مرة لكل مفتاح، مع ربط K بذلك المفتاح، فيكون T[K] نوع تلك الخاصية. أضف phone إلى Address فيكتسب AddressForm مدخلًا لـ phone، دون أي تعديل.
لاحظ أن postcode بقيت اختيارية. حين يمرّ الـ mapped type على keyof T مباشرة، يعامله TypeScript على أنه تماثليّ الشكل (homomorphic): ينسخ معدِّلَي ? وreadonly لكل خاصية من T. وهذا ما تريده عادة، وهو سبب احتفاظ الـ utility types المدمجة بالاختيارية.
لست مضطرًا إلى المرور على keyof. أي union من المفاتيح يعمل، وهذا كل ما في Record:
type MyRecord<K extends PropertyKey, V> = { [P in K]: V };
type CountsByStatus = MyRecord<'processing' | 'shipped' | 'delivered', number>;إضافة المعدِّلات وإزالتها
ضع ? أو readonly أمام الخاصية لإضافتها، وابدأ بـ - لإزالتها. بهذا تصبح معظم الـ utility types في الـ lib سطرًا واحدًا لكلٍّ منها:
type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };MyPartial<Address> هو نوع جسم طلب PATCH. وMyReadonly<Address> هو النسخة المجمّدة لشاشة المراجعة. و-? تفعل شيئًا إضافيًا قد لا تتوقّعه: تُزيل أيضًا الـ undefined الذي تحمله الخاصية الاختيارية ضمنيًا، فيكون MyRequired<Address>['postcode'] هو string، لا string | undefined.
MyPick يستحق نظرة ثانية. إنه يمرّ على K لا على keyof T، لكن لأن K مقيَّد بـ keyof T، يظل TypeScript يعامله كتماثليّ الشكل ويحتفظ بمعدِّلات الخصائص المختارة.
إعادة تسمية المفاتيح بـ as
منذ TypeScript 4.1، يستطيع الـ mapped type إعادة تسمية كل مفتاح بجملة as. يمكن أن يكون الاسم الجديد أي تعبير نوعي يتضمّن K، وعادةً ما يكون template literal:
type Setters<T> = {
[K in keyof T as `set${Capitalize<K & string>}`]: (value: T[K]) => void;
};
type AddressSetters = Setters<Address>;
// { setLine1: (value: string) => void; setCity: …; setCountry: …; setPostcode?: … }K & string ضرورية لأن keyof T قد يتضمّن مفاتيح number وsymbol، وCapitalize لا تعمل إلا على النصوص. سترى الـ template literal types كما ينبغي في الـ Template Literal Types.
ولجملة as قوة ثانية: المفتاح المحوَّل إلى never يُحذف. وهكذا بالضبط يُعرَّف Omit:
type MyOmit<T, K extends PropertyKey> = { [P in keyof T as Exclude<P, K>]: T[P] };
type NewAddress = MyOmit<Address, 'postcode'>; // { line1; city; country }Exclude<P, K> تعطي never حين يكون P أحد المفاتيح المحذوفة، وP في غير ذلك. في الدرس القادم سترى كيف تعمل Exclude، وكيف تصفّي المفاتيح حسب نوع قيمتها لا حسب اسمها.
وضعها موضع التطبيق
أنواع مثل FieldState تؤتي ثمارها في الدوال التي تستخدمها. إليك دالة تحديث النموذج، مكتوبة مرة واحدة لأي نموذج، لا للعناوين فقط:
function setField<T, K extends keyof T>(form: FieldState<T>, key: K, value: T[K]): FieldState<T> {
return { ...form, [key]: { ...form[key], value, touched: true } };
}
setField(addressForm, 'city', 'Dubai'); // ok
setField(addressForm, 'city', 42); // Error: Argument of type 'number' is not assignable to parameter of type 'string'.
setField(addressForm, 'cty', 'Dubai'); // Error: Argument of type '"cty"' is not assignable to parameter of type 'keyof Address'.هذا نمط pluck من القسم 2 مدموجًا مع mapped type. يُستنتج K من المفتاح الذي تمرّره، وتفحص T[K] القيمة مقابل نوع ذلك الحقل، ويضمن الـ mapped type أن للنموذج مدخلًا لكل حقل. وحين يُضاف حقل deliveryNotes إلى Address، يقبله نوع النموذج والـ setters وهذه الدالة فورًا، ويُعلَّم كل مكان يبني نموذجًا كاملًا إلى أن يتعامل مع الحقل الجديد.
في التمرين ستعيد بناء أربعة utility types وتبني أنواع نموذج العنوان منها. والدرس القادم يضيف القطعة الناقصة، الشروط، كي تستطيع تحويلاتك اتخاذ القرارات.
أهم النقاط
- الـ mapped type بالشكل
{ [K in Keys]: … }يمرّ على union من المفاتيح ويبني خاصية واحدة لكل مفتاح. - المرور على
keyof Tتماثليّ الشكل (homomorphic)، فتُنسخ معدِّلات الاختيارية وreadonly منTما لم تُضفها أو تُزلها أنت. +?و-?و+readonlyو-readonlyتضيف المعدِّلات أو تنزعها؛ وRequired<T>ليس إلا{ [K in keyof T]-?: T[K] }.- جملة
asتعيد تسمية كل مفتاح، وتحويل مفتاح إلىneverيحذفه، وهكذا يعملOmit. - الـ mapped types المدمجة سطحية؛ الكائنات المتداخلة تحتفظ بمعدِّلاتها الأصلية.