EduFlow
أنماط TypeScript المتقدّمة
⁦0%⁩

الوحدة 3 · البرمجة على مستوى الأنواع

الـ Mapped Types وإعادة تسمية المفاتيح

19 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

حوّل كل خصائص النوع دفعة واحدة بالـ mapped types، وأضف معدِّلات readonly والاختيارية أو أزلها، وأعِد تسمية المفاتيح أو احذفها بجمل as، معيدًا بناء Partial وPick وOmit في الطريق.

  1. 1الحلقة
  2. 2إضافة المعدِّلات وإزالتها
  3. 3إعادة تسمية المفاتيح بـ as
  4. 4وضعها موضع التطبيق

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

في صفحة الدفع في Cartwheel نموذج للعنوان. نوع العنوان بسيط:

TypeScript
type Address = { line1: string; city: string; country: string; postcode?: string };

لكن النموذج يحتاج إلى أكثر من العنوان. يحتاج لكل حقل إلى قيمته الحالية، وخطأ التحقّق، وما إذا كان المستخدم قد لمسه. ويحتاج الـ endpoint الخاص بـ PATCH إلى نسخة كل حقولها اختيارية. وتحتاج شاشة المراجعة إلى نسخة مجمّدة. يمكنك كتابة ثلاثة أنواع أخرى يدويًا، كلٌّ منها يكرّر أسماء الحقول الأربعة، وتحديثها كلها كلما تغيّر Address. أو يمكنك كتابة كلٍّ منها مرة واحدة، كتحويل. هذا هو الـ mapped type (النوع المحوَّل): حلقة تكرار على المفاتيح، على مستوى الأنواع.

الحلقة

TypeScript
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، دون أي تعديل.

مفاتيح Address، وهي line1 وcity وcountry وpostcode، يمرّ كلٌّ منها عبر جسم الـ mapped type، الذي يغلّف نوع الخاصية في كائن فيه value وerror وtouched، فينتج خاصية واحدة لكل مفتاح في FieldState الخاص بـ Address. keyof Address 'line1' 'city' 'country' 'postcode' [K in keyof T]: { value: T[K]; error; touched } FieldState line1: {…} city: {…} country: {…} postcode?: {…}
الـ mapped type يُنفّذ جسمه مرة لكل مفتاح

لاحظ أن postcode بقيت اختيارية. حين يمرّ الـ mapped type على keyof T مباشرة، يعامله TypeScript على أنه تماثليّ الشكل (homomorphic): ينسخ معدِّلَي ? وreadonly لكل خاصية من T. وهذا ما تريده عادة، وهو سبب احتفاظ الـ utility types المدمجة بالاختيارية.

لست مضطرًا إلى المرور على keyof. أي union من المفاتيح يعمل، وهذا كل ما في Record:

TypeScript
type MyRecord<K extends PropertyKey, V> = { [P in K]: V };

type CountsByStatus = MyRecord<'processing' | 'shipped' | 'delivered', number>;

إضافة المعدِّلات وإزالتها

ضع ? أو readonly أمام الخاصية لإضافتها، وابدأ بـ - لإزالتها. بهذا تصبح معظم الـ utility types في الـ lib سطرًا واحدًا لكلٍّ منها:

TypeScript
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:

TypeScript
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:

TypeScript
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 تؤتي ثمارها في الدوال التي تستخدمها. إليك دالة تحديث النموذج، مكتوبة مرة واحدة لأي نموذج، لا للعناوين فقط:

TypeScript
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 المدمجة سطحية؛ الكائنات المتداخلة تحتفظ بمعدِّلاتها الأصلية.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

ما هو Draft؟

TypeScript
type Address = { readonly line1: string; city: string; postcode?: string };
type Draft = { -readonly [K in keyof Address]-?: Address[K] };
السؤال 2 من 4

أيّ تعريف يعيد بناء Omit<T, K>؟

السؤال 3 من 4

type Setters<T> = { [K in keyof T as set${Capitalize}]: (v: T[K]) => void } لا يمرّ في الترجمة. لماذا؟

السؤال 4 من 4

طُبّق Readonly<Order> على type Order = { id: number; lines: { sku: string }[] }. أيّ إسناد ما زال مسموحًا؟

تدريب

بيئة تجريب الكود

أعِد بناء الـ utility types

باستخدام الـ mapped types وحدها (بلا Partial أو Required أو Pick أو Omit أو Record المدمجة)، نفّذ:

  • MyPartial<T> وMyRequired<T> وMyPick<T, K> وMyOmit<T, K>، بسلوك يطابق المدمجة.
  • FieldState<T>: لكل خاصية، { value: T[K]; error: string | null; touched: boolean }.
  • Setters<T>: لكل خاصية name، خاصية setName هي دالة تأخذ نوع تلك الخاصية وتُعيد void.

ثم اجعل initialState تمرّ في الترجمة بنوع إرجاعها الجديد.

type Address = { line1: string; city: string; country: string; postcode?: string };

// Re-implement these with mapped types (do not use the built-in utility types).
type MyPartial<T> = T;
type MyRequired<T> = T;
type MyPick<T, K extends keyof T> = T;
type MyOmit<T, K extends PropertyKey> = T;

// For each property: its value, a validation error and whether it was touched.
type FieldState<T> = any;

// For each property `name`, a `setName(value)` function.
type Setters<T> = any;

function initialState<T extends object>(values: T): FieldState<T> {
  return Object.fromEntries(
    Object.entries(values).map(([key, value]) => [key, { value, error: null, touched: false }]),
  );
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<MyPartial<Address>, { line1?: string; city?: string; country?: string; postcode?: string }>>;
type T2 = Expect<Equal<MyRequired<Address>, { line1: string; city: string; country: string; postcode: string }>>;
type T3 = Expect<Equal<MyPick<Address, 'city' | 'postcode'>, { city: string; postcode?: string }>>;
type T4 = Expect<Equal<MyOmit<Address, 'postcode' | 'country'>, { line1: string; city: string }>>;
type T5 = Expect<Equal<FieldState<{ city: string; floor: number }>, {
  city: { value: string; error: string | null; touched: boolean };
  floor: { value: number; error: string | null; touched: boolean };
}>>;
type T6 = Expect<Equal<Setters<{ city: string; floor: number }>, {
  setCity: (value: string) => void;
  setFloor: (value: number) => void;
}>>;

const form = initialState({ line1: '12 Nile St', city: 'Cairo', country: 'EG' });
console.log(form.city);
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • initialState تبني لكل حقل حالة غير ملموسة وخالية من الأخطاء