TYPE ⊆ SET منهج TypeScriptمن الجذور tsc --noEmit ✓
الإقليم ٨08

البرمجة على الأنواع Type-Level

conditional / infer / mapped / recursion


النبذة

الآن نكتب برامج تعمل قيمها أنواعٌ وزمنها وقت الترجمة. أربع أدوات تكفي لجعل نظام الأنواع لغةً كاملة: الشرط (conditional types) = if، infer = تفكيك / pattern matching، mapped types = حلقة على المفاتيح، والعودية = التكرار. بهذه الأربع تحصل على شروط وتفرّع واستخراج وحلقات وعودية — كل ما تحتاجه لغة. وفي نهاية الإقليم ستكتب نوعاً يحسب فعلاً، وستفهم لماذا يُقال إن نظام أنواع TypeScript مكتمل تورنغ (Turing-complete) — اللمحة التي زرعناها في README منذ البداية.


اللغز المستفزّ

أنواعٌ كثيرة في الكود الحقيقي مشتقّةٌ من أنواعٍ أخرى. مثال: لديك دالة، وتريد نوع ما تُرجعه دون أن تكتبه يدوياً (لأنه قد يتغيّر، ولا تريد التكرار):

typescript
function makeUser(name: string) { return { id: Math.random(), name, createdAt: new Date() }; } // أريد نوعاً اسمه User = نوع ما تُرجعه makeUser، يُحسب تلقائياً. type User = /* ؟؟؟ */;

كتابته يدوياً ({ id: number; name: string; createdAt: Date }) تكرارٌ هشّ: لو غيّرت الدالة، نسي النوع المواكبة. تريد أن تستخرج نوع الإرجاع من الدالة، آلياً. لكن نوع الإرجاع مدفونٌ داخل نوع الدالة (name: string) => {...}. كيف تفتح نوعاً وتنتزع جزءاً منه؟ بأدوات الإقليم ٧ وحدها، لا تستطيع. تحتاج أن تطابق نمطاً على بنية النوع وتربط متغيّراً بالجزء المطلوب. هذه infer. لكن لنبنِ إليها بالترتيب.


الدرس

١) الأنواع الشرطية: if على مستوى الأنواع

typescript
type IsString<T> = T extends string ? true : false; type A = IsString<"hi">; // true type B = IsString<42>; // false

T extends U ? X : Y تُقرأ: "إن كان T ⊆ U (نوعٌ فرعي)، فالنتيجة X، وإلا Y". لاحظ: extends هنا هي نفسها extends القيد من الإقليم ٧ — كلتاهما تعني اختبار . هنا فقط نستعمل نتيجة الاختبار لنتفرّع. هذا if كامل، قيمه أنواع.

مثال مفيد فوراً — استبعاد null:

typescript
type NonNull<T> = T extends null | undefined ? never : T; type C = NonNull<string | null>; // string (الفرع null صار never فاختفى — انظر التوزيع)

التوزيع على الاتحادات (distribution) — الجوهرة والفخّ معاً

أهمّ وأخدع سلوكٍ في الأنواع الشرطية: حين يكون T اتحاداً ومتغيّر نوعٍ عارياً (naked)، يُطبَّق الشرط على كل عضوٍ منفصلاً، ثم تُجمع النتائج باتحاد:

typescript
type ToArray<T> = T extends any ? T[] : never; type R = ToArray<string | number>; // ليست (string | number)[] ! // بل تُوزَّع: ToArray<string> | ToArray<number> = string[] | number[]

ليش هذا السلوك؟ لأنه يطابق الحدْس المجموعي: تعمل على "كل احتمالٍ في الاتحاد على حدة". إنه قوّةٌ هائلة (به نبني NonNull أعلاه: كل عضوٍ يُختبر، عضو null يصير never فيتبخّر من الاتحاد لأن X | never = X). لكنه فخٌّ شهير: أحياناً تريد معاملة الاتحاد ككلٍّ لا توزيعه، فتُعطِّل التوزيع بتغليف الطرفين: [T] extends [U] ? .... التغليف في tuple يمنع T من كونه "عارياً" فيُلغي التوزيع. تذكّر هذا؛ سيوفّر عليك ساعات حيرة.

٢) infer: تفكيك الأنواع ومطابقة النمط

الآن نحلّ لغز المقدّمة. infer X تقول داخل شرط: "طابِق هذا النمط، واربط الجزء المجهول باسمٍ X أستعمله في فرع النجاح":

typescript
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type User = ReturnType<typeof makeUser>; // { id: number; name: string; createdAt: Date } — مُستخرَج آلياً!

اقرأها: "إن كان T على شكل دالةٍ تُرجع شيئاً ما، سمِّ ذلك الشيء R وأعِده". infer R كـ "ثقبٍ" في نمطٍ تطلب من المترجم ملأه بما يطابق. هذا pattern matching على بنية الأنواع — تماماً كتفكيك [a, b] = pair في القيم، لكن على الأنواع. وبه تستخرج من أي بنية:

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never; type E1 = ElementOf<number[]>; // number type Awaited2<T> = T extends Promise<infer V> ? V : T; type V1 = Awaited2<Promise<string>>; // string

(هذه الأنماط شائعة جداً، ولهذا TypeScript تشحن ReturnType, Parameters, Awaited جاهزةً في المكتبة القياسية — لكن الآن تعرف أنها ليست سحراً، بل infer في شرط. قراءة مصدرها تمرينٌ ممتاز: ابحث عن ReturnType في lib.es5.d.ts.)

٣) Mapped Types: حلقةٌ على المفاتيح

typescript
type Readonly2<T> = { readonly [K in keyof T]: T[K] }; type Partial2<T> = { [K in keyof T]?: T[K] };

[K in keyof T] = "لكل مفتاحٍ K في مجموعة مفاتيح T، ولّد خاصية". إنها حلقة for على مستوى الأنواع. keyof T يعطيك اتحاد المفاتيح (مجموعتها)، وK in يتكرّر عليها. مع كل دورة تستطيع تعديل النوع (T[K]) أو المُعدِّلات (readonly, ?). بالإشارة +/- تضيف أو تنزع مُعدِّلاً: -readonly يزيل الثبات، -? يجعل الكل إلزامياً:

typescript
type Mutable<T> = { -readonly [K in keyof T]: T[K] }; type Required2<T> = { [K in keyof T]-?: T[K] };

وبـ as تُعيد تسمية المفاتيح (key remapping) — حلقةٌ تبني مفاتيح جديدة:

typescript
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }; // { name: string } → { getName: () => string }

لاحظ `get${...}`template literal types: نصوصٌ تُبنى على مستوى الأنواع، تماماً كقوالب النصوص في JavaScript لكنها تنتج أنواعاً. بها تحسب أسماء مفاتيح، وتُفكّك نصوصاً (infer داخل قالب نصّي)، وتبني واجهاتٍ كاملة من نصّ.

٤) العودية: التكرار، ومنه اكتمال تورنغ

النوع يستطيع استدعاء نفسه. وهنا يصير "لغة برمجة" حرفياً:

typescript
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] };

استدعى DeepReadonly نفسه على القيم التي هي كائنات → ثباتٌ متغلغل لأي عمق. ومثالٌ يحسب فعلاً — اعكس tuple بالعودية:

typescript
type Reverse<T extends any[]> = T extends [infer Head, ...infer Tail] ? [...Reverse<Tail>, Head] : []; type R = Reverse<[1, 2, 3]>; // [3, 2, 1]

تأمّل ما حدث: شرط (if)، تفكيك بـ infer (head/tail)، واستدعاء ذاتي (تكرار). هذه عناصر الحوسبة كلها. ولهذا نظام أنواع TypeScript مكتمل تورنغ: يمكنك — نظرياً — حساب أي دالةٍ قابلة للحساب فيه، وقت الترجمة، قبل أن يعمل برنامجك بلحظة. الناس كتبوا فيه مفسّراتٍ ومحلّلاتٍ نحوية، كلها تختفي عند التشغيل تاركةً وراءها نوعاً واحداً مُحسَباً. هذه هي بذرة الغموض من README، وقد أزهرت بيدك.

نموذج ذهني

النموذج الذهني الكبير: لديك الآن لغتان متراكبتان. لغة القيم (JavaScript، تعمل وقت التشغيل) ولغة الأنواع (تعمل وقت الترجمة ثم تُمحى — جدار الإقليم ٠). conditional = if، infer = تفكيك، mapped = حلقة، recursion = تكرار. كل ما تعرفه عن البرمجة، له صدىً في عالم الأنواع. لكن تذكّر: مهما برمجتَ هنا، النتيجة تختفي قبل التشغيل. أنت تبرهن، لا تُنفّذ.

تحذير

تحذيرٌ من القاتل: هذه القوّة مُغرية للإفراط. أنواعٌ عودية عميقة تُبطئ المترجم وتُنتج رسائل خطأ مرعبة، وقد تصطدم بحدّ العمق (TS يوقف العودية حول ~٥٠ مستوى حمايةً من التعليق). البرمجة على مستوى الأنواع أداةٌ للمكتبات والحالات التي تستحقّ، لا للاستعراض في كل سطر. الأناقة أن تستعملها حين تشتري سلامةً حقيقية، لا لأنك تستطيع.


اللغز / التمرين

(أ) اكتب type First<T extends any[]> = ... تُعيد نوع أول عنصرٍ في tuple (First<[string, number]> = string)، و type Last<T> للأخير. ستحتاج infer ومطابقة نمط tuple. تنبّأ بـ First<[]> — ماذا يجب أن يكون، ولماذا never مناسب؟

(ب) — التوزيع. تنبّأ بكل نتيجة، ثم تحقّق، وفسّر أين وزّع المترجم وأين لا:

typescript
type Box<T> = T extends any ? { v: T } : never; type R1 = Box<string | number>; // ؟ type Eq<T> = [T] extends [string] ? "yes" : "no"; type R2 = Eq<string | number>; // ؟ لماذا التغليف غيّر الجواب؟ type Excl<T, U> = T extends U ? never : T; // أعد بناء Exclude بنفسك type R3 = Excl<"a" | "b" | "c", "b">; // ؟

(ج) — استخراج. بـ infer وحدها، اكتب: type UnwrapPromise<T> تنزع Promise (وتتركه إن لم يكن وعداً)، و type FnArgs<T> تستخرج tuple معاملات دالة. جرّبهما، ثم افتح lib.es5.d.ts (في المحرّر: انتقل لتعريف Parameters) وقارن حلّك بتعريف TypeScript الرسمي. أين اختلفتما، ولماذا؟

(د) — اللغز الكبير: نوعٌ يحسب. اكتب type Length<T extends any[]> تُعيد طول tuple كنوعٍ رقمي حرفي (Length<[1,2,3]> = 3). ثم — الأصعب — اكتب type BuildTuple<N extends number> تبني tuple بطول N مملوءاً بأي شيء (BuildTuple<3> = [unknown, unknown, unknown]). ستحتاج العودية وبناء tuple خطوةً خطوة مع عدٍّ. حين تنجح، ستكون قد جعلت نظام الأنواع يعدّ. لا حلّ هنا؛ سؤال موجّه واحد: العدّ على مستوى الأنواع لا يملك + رقمياً — فكيف "تزيد واحداً" باستعمال طول tuple بدل الحساب؟ (هذه الحيلة — العدّ بطول tuple — هي مفتاح كل حسابٍ رقميٍّ على مستوى الأنواع.)


الخلاصة — وصلٌ في الشجرة

جعلنا نظام الأنواع لغة برمجة:

  • conditional T extends U ? X : Y = if؛ والتوزيع يطبّقه على كل عضو اتحاد (قوّة وفخّ؛ عطّله بـ [T] extends [U]).
  • infer X = تفكيك / pattern matching يستخرج أجزاءً من بنية النوع — حلّ مشكلة "اقرأ النوع من الدالة/المصفوفة/الوعد".
  • mapped types [K in keyof T] = حلقة على المفاتيح، مع مُعدِّلات +/- و إعادة تسمية as و template literal types.
  • العودية تكمل الصورة → النظام مكتمل تورنغ: شرط + تفكيك + حلقة + تكرار. وكل هذا يُحسب وقت الترجمة ثم يُمحى (جدار الإقليم ٠).

ربطنا للوراء: extends هي (١)، التوزيع هو منطق الاتحادات (٣)، keyof/T[K] من (٧)، والتباين يسري في كل موضعٍ يظهر فيه T. التحم نصفا المنهج: عدسة المجموعات صارت لغةً تبرمج بها المجموعات نفسها.

بذرة غموض

بذرة غموض — وهي مدخل الإقليم الأخير: برمجنا بالأنواع بثقةٍ تامّة، وبنينا براهين أنيقة. لكن لا تنسَ أبداً الشجرة الموازية التي نمت بصمتٍ منذ الإقليم ٠: كل هذا يُمحى قبل التشغيل. نوعٌ يقول User لا يضمن أن البيانات القادمة من الشبكة هي فعلاً User. أثبتَّ نظرية عن عالمٍ لا تستطيع رؤيته لحظة تنفيذه. أين ينكسر النظام إذاً؟ ما الثقوب التي تركتها اللغة؟ ولماذا — رياضياً — لا يمكن لأي نظامٍ كهذا أن يكون كاملاً وسليماً معاً؟ وكيف تستعيد بعض ما تخلّت عنه (الأنواع الاسمية)؟ هنا تلتحم الشجرتان أخيراً. للإقليم ٩.

التالي: 09-the-edges.md

منهج TypeScript — من الجذور · نظرية مجموعات + مُبرهِن نظريات النوع = مجموعة