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

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

الأنواع التعاودية ومتى تتوقّف

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

الدرس في لمحة

اكتب أنواعًا تعاودية للأشجار وJSON والتحويلات العميقة، وافهم حدود العمق في الـ compiler، وقرّر متى يكلّف النوع الذكي أكثر من الخطأ الذي يمنعه.

  1. 1أنواع تشير إلى نفسها
  2. 2التحويلات العميقة
  3. 3المسارات، وكلفة الذكاء

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

فئات المنتجات في Cartwheel تشكّل شجرة: Kitchen تحتوي Cookware، التي تحتوي Pans. يُعيد الـ API هذه الشجرة كـ JSON متداخل، وتعدّل شاشة إعدادات الدفع كائن إعداد متداخلًا بعمق. كلاهما يحتاج إلى أنواع تشير إلى نفسها. يتعامل TypeScript مع ذلك جيدًا، إلى حدٍّ ما. هذا الدرس يغطّي كيف تكتب الأنواع التعاودية، وأين تقع الحدود، والقرار التقديري الذي كان هذا القسم كله يمهّد له: متى تتوقّف.

أنواع تشير إلى نفسها

يمكن للاسم المستعار للنوع أن يذكر نفسه داخل نوع كائن أو مصفوفة:

TypeScript
type Category = { id: number; name: string; children: Category[] };

type Json = string | number | boolean | null | Json[] | { [key: string]: Json };

Category يصف شجرة بأي عمق. وJson يصف أي قيمة يمكن أن تُعيدها JSON.parse، ما يجعله نوع إرجاع أفضل بكثير لمحلّل من any: إنه صادق، ويجبرك على التضييق قبل الاستخدام.

الدوال التعاودية على مستوى الأنواع تتبع الفكرة نفسها. Awaited<T>، المدمج في الـ lib منذ TypeScript 4.5، يفكّ الـ promises حتى لا يبقى منها شيء. إليك نسخة مبسّطة:

TypeScript
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;

type Loaded = MyAwaited<Promise<Promise<Order>>>; // Order

كل خطوة تقشر Promise واحدًا وتستدعي نفسها على ما كان بداخله. وحين لا يعود النوع promise، يُعيده الفرع الكاذب وينتهي التعاود.

التحويلات العميقة

الـ mapped types من الـ Mapped Types وإعادة تسمية المفاتيح سطحية. والتعاود يجعلها عميقة:

TypeScript
type DeepReadonly<T> = T extends (...args: any[]) => any
  ? T
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

type FrozenCheckout = DeepReadonly<Checkout>;

لكل حالة سببها. الأنواع الأولية تمرّ دون تغيير. والكائنات تُحوَّل، وتُعالَج كل خاصية تعاوديًا. والدوال لها فرعها الخاص لأن الـ mapped type يحتفظ بالخصائص لا بتوقيعات الاستدعاء: دون هذا الفرع، سيتحوّل handler مثل onSubmit إلى كائن لا يمكنك استدعاؤه. أما المصفوفات فلا تحتاج حالة خاصة هنا، لأن الـ mapped type التماثلي المطبَّق على مصفوفة ينتج مصفوفة للقراءة فقط.

DeepPartial هو الآخر الذي ستصادفه، عادةً لبيانات الاختبار الجاهزة (fixtures) ولتجاوزات الإعداد:

TypeScript
type DeepPartial<T> = T extends readonly unknown[]
  ? T
  : T extends object
    ? { [K in keyof T]?: DeepPartial<T[K]> }
    : T;

declare function checkoutFixture(overrides?: DeepPartial<Checkout>): Checkout;
checkoutFixture({ shipping: { address: { country: 'JO' } } });

هنا تُحفظ المصفوفات كاملة عن قصد: مصفوفة جزئية من بنود جزئية نادرًا ما تكون ما يقصده الاختبار.

المسارات، وكلفة الذكاء

هذا هو النوع الذي يلجأ إليه الناس حين يريدون أن تُفحص get(checkout, 'shipping.address.city'):

TypeScript
type Paths<T> = {
  [K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
}[keyof T & string];

type CheckoutPath = Paths<{ shipping: { address: { city: string; country: string } }; totalCents: number }>;
// 'shipping' | 'shipping.address' | 'shipping.address.city' | 'shipping.address.country' | 'totalCents'

إنه يعمل، وعلى نوع صغير يكون ممتعًا. لكنه أيضًا mapped type وشرط وtemplate literal وتعاود في أربعة أسطر، وكلفته تنمو مع كل حقل وكل مستوى. على كائن حالة حقيقي من 40 حقلًا فيه مصفوفات وتواريخ، قد ينتج نوع كهذا آلاف الأعضاء ويجعل كل ضغطة مفتاح في المحرّر أبطأ.

ولدى TypeScript أيضًا حدود صارمة. كل تجسيد متداخل يُحسب، وبعد بضع عشرات من مستويات التعاود العادي تحصل على Type instantiation is excessively deep and possibly infinite. ومنذ TypeScript 4.5، يُحسَّن الـ conditional type الذي يكون استدعاؤه التعاودي في موضع ذيلي (tail position) (أي النتيجة الكاملة لفرع، كما في MyAwaited)، ويمكن أن يُنفَّذ نحو 1,000 تكرار. ولا يوجد خيار في الـ compiler لرفع أيٍّ من الحدّين.

الأنواع التعاودية هي الأداة الصحيحة للبيانات التعاودية فعلًا: الأشجار، وJSON، والإعدادات المتداخلة. ولكل ما عدا ذلك، اكتب النوع الممل أولًا، ولا تلجأ إلى الذكي إلا حين يبرّره خطأ حقيقي.

في التمرين ستكتب MyAwaited وDeepReadonly وDeepPartial، وتستخدم الأخير لبيانات اختبار جاهزة. وبه يُختم قسم مستوى الأنواع. القسم 4 ينتقل إلى الأنماط: ما تبنيه الفرق المتمرّسة فعلًا بكل هذا.

أهم النقاط

  • يمكن للاسم المستعار للنوع (type alias) أن يشير إلى نفسه، وهكذا تصف الأشجار وJSON وغيرها من البيانات المتداخلة.
  • الدوال التعاودية على مستوى الأنواع مثل DeepReadonly وDeepPartial تحتاج إلى حالات صريحة للدوال والمصفوفات، وإلا حوّلت أشياء لم تقصد لمسها.
  • يوقف TypeScript التعاود بعد بضع عشرات من المستويات المتداخلة، أو نحو 1,000 للـ conditional types ذات التعاود الذيلي، برسالة “Type instantiation is excessively deep”.
  • readonly تمنع فقط إسناد الخصائص؛ أما methods مثل Date#setFullYear أو Map#set فما زالت تعدّل القيمة المعرَّفة للقراءة فقط بعمق.
  • قبل اعتماد نوع ذكي، اسأل ما الخطأ الذي يمنعه، وهل يستطيع زميل قراءة أخطائه، وكم يكلّف المحرّر.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

بهذا التعريف، ما نوع o.lines؟

TypeScript
type DeepReadonly<T> = T extends (...args: any[]) => any
  ? T
  : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
declare const o: DeepReadonly<{ lines: { sku: string }[] }>;
السؤال 2 من 4

لماذا يحتاج نوع DeepReadonly عادةً إلى فرع منفصل للدوال؟

السؤال 3 من 4

MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T. ما هو MyAwaited<Promise<Promise<Order>>>؟

السؤال 4 من 4

يضيف زميل نوع Paths<T> يولّد كل مسار بالنقاط لحالة الدفع ذات الأربعين حقلًا، فيصبح المحرّر بطيئًا. ما الإصلاح الأكثر واقعية؟

تدريب

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

تعمّق، بحذر

نفّذ ثلاثة أنواع تعاودية (لا تستخدم Awaited المدمج):

  • MyAwaited<T>: فكّ الـ promises المتداخلة حتى لا يعود النوع promise.
  • DeepReadonly<T>: اجعل كل خاصية في كل مستوى readonly، وحوّل المصفوفات إلى مصفوفات للقراءة فقط من عناصر للقراءة فقط بعمق، واترك الدوال دون مساس (قابلة للاستدعاء).
  • DeepPartial<T>: اجعل كل خاصية في كل مستوى اختيارية، لكن أبقِ المصفوفات كما هي (البيانات الجاهزة تستبدل المصفوفة كاملة).

checkoutFixture تدمج التجاوزات وقت التشغيل أصلًا؛ وحين يصبح DeepPartial صحيحًا، يمرّ التجاوز المتداخل في المثال في الترجمة.

type Checkout = {
  id: number;
  customer: { email: string; country: string };
  shipping: { method: 'standard' | 'express'; address: { line1: string; city: string } };
  lines: { sku: string; quantity: number }[];
  onSubmit: () => void;
};

type MyAwaited<T> = T;
type DeepReadonly<T> = Readonly<T>;
type DeepPartial<T> = Partial<T>;

const defaultCheckout: Checkout = {
  id: 1042,
  customer: { email: 'mona@example.com', country: 'EG' },
  shipping: { method: 'standard', address: { line1: '12 Nile St', city: 'Cairo' } },
  lines: [{ sku: 'TENT-2P', quantity: 1 }],
  onSubmit: () => {},
};

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function mergeDeep(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = { ...base };
  for (const [key, value] of Object.entries(overrides)) {
    if (value === undefined) continue;
    const current = out[key];
    out[key] = isPlainObject(current) && isPlainObject(value) ? mergeDeep(current, value) : value;
  }
  return out;
}

function checkoutFixture(overrides: DeepPartial<Checkout> = {}): Checkout {
  // mergeDeep works on untyped records; the result has Checkout's shape by construction.
  return mergeDeep(defaultCheckout, overrides as Record<string, unknown>) as Checkout;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<MyAwaited<Promise<Promise<Checkout>>>, Checkout>>;
type T2 = Expect<Equal<MyAwaited<string>, string>>;

type Frozen = DeepReadonly<Checkout>;
type T3 = Expect<Equal<Frozen['shipping']['address'], { readonly line1: string; readonly city: string }>>;
type T4 = Expect<Equal<Frozen['lines'], readonly { readonly sku: string; readonly quantity: number }[]>>;
type T5 = Expect<Equal<Frozen['onSubmit'], () => void>>;

type T6 = Expect<Equal<DeepPartial<Checkout>['shipping'], { method?: 'standard' | 'express'; address?: { line1?: string; city?: string } } | undefined>>;
type T7 = Expect<Equal<DeepPartial<Checkout>['lines'], { sku: string; quantity: number }[] | undefined>>;

function mutationTest(frozen: Frozen) {
  // @ts-expect-error: nested properties are readonly
  frozen.shipping.address.city = 'Amman';
}

const fixture = checkoutFixture({ shipping: { address: { city: 'Amman' } }, customer: { country: 'JO' } });
console.log(fixture.shipping.address, fixture.customer);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • checkoutFixture تدمج التجاوزات المتداخلة في القيم الافتراضية