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

التضييق Narrowing

كيف يفكّر المُبرهِن


النبذة

هنا يصير المُبرهِن حيّاً. حتى الآن عاملنا النوع كثابتٍ ملتصق بالمتغيّر. الحقيقة أعمق وأجمل: نوع المتغيّر يتغيّر من سطرٍ لآخر بحسب ما تُثبته عنه أكواد الفحص. المترجم يقرأ منطق برنامجك — if, typeof, ===, return — ويُحدّث، عند كل نقطة، أصغرَ مجموعةٍ يمكن أن تكون القيمة فيها هنا. هذا تحليل تدفّق التحكّم (control-flow analysis)، وهو ما يجعل الاتحادات قابلة للاستعمال. إنه أيضاً أقرب ما تراه لـ "المترجم يفكّر مثلك".


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

ثلاثة مقاطع. في كلٍّ، اسأل: ما نوع x عند السطر المُعلَّم؟ ولماذا تغيّر عمّا أُعلِن؟

typescript
function a(x: string | number) { if (typeof x === "string") { x; // (1) النوع هنا؟ } else { x; // (2) النوع هنا؟ } } function b(x: string | null) { if (x === null) return; x; // (3) النوع هنا؟ (لاحظ: عُدنا مبكراً) } function c(x: string | number | boolean) { if (typeof x === "string") return; if (typeof x === "number") return; x; // (4) النوع هنا؟ بقي طرفٌ واحد... }

اللغز الحقيقي خلف هذا: المترجم لم يُشغّل برنامجك (الأنواع تُمحى، لا يوجد تنفيذ). فكيف "عرف" أن x نصٌّ داخل if؟ هو لم يرَ القيمة. ماذا يقرأ بالضبط ليطرح {أرقام} من المجموعة عند فرع else؟


الدرس

الفكرة المركزية: التضييق = طرحٌ من المجموعة موجَّهٌ بالمنطق

المُبرهِن لا يعرف القيمة، لكنه يعرف شيئاً قوياً: معنى الفحوص. إنه يحمل جدولاً داخلياً: "التعبير typeof e === 'string' صحيحٌ ⟺ قيمة e في مجموعة string". هذه حقيقةٌ مبرهَنة عن JavaScript نفسها، مُرمَّزة داخل المترجم. فحين يرى:

typescript
if (typeof x === "string") { /* فرع A */ } else { /* فرع B */ }

يستدلّ: "داخل فرع A، الشرط صادق، فـ x بالضرورة في string. وداخل فرع B، الشرط كاذب، فـ x بالضرورة ليست في string — أي مجموعتها الأصلية ناقص string."

طبّق على اللغز الأول، x: string | number:

التضييق إذاً عمليّتا مجموعات بسيطتان — تقاطع في الفرع الموجب، طرح في الفرع السالب — موجَّهتان بدلالة الفحص. لا سحر؛ جبر مجموعات يقوده المترجم باستعمال معرفته بمعنى كل بنية فحص في JavaScript.

تدفّق التحكّم: النوع دالة في الموضع، لا في المتغيّر

اللغز (3) يكشف الأعمق. لا يوجد else، فقط return مبكر:

typescript
function b(x: string | null) { if (x === null) return; // إن كانت null، غادرنا x; // فأي تدفّق يصل هنا أزال احتمال null }

المترجم يبني رسم تدفّق (flow graph) لبرنامجك: ما المسارات التي تصل لكل نقطة؟ السطر الأخير يصله فقط المسار الذي لم يدخل return، أي حيث x !== null. فعند ذلك الموضع، يطرح {null}: النوع string.

ملاحظة

هذه نقطة جوهرية تنسف فهماً خاطئاً شائعاً: **النوع ليس صفةً للمتغيّر، بل صفةٌ للمتغيّر عند موضعٍ معيّن في التدفّق.** نفس x نوعه string | null في سطر، و string في السطر التالي، لأن المعلومات المتراكمة على المسار اختلفت. المترجم يعيد حساب المجموعة عند كل عقدة في الرسم. هذا ما يجعل early return و throw و continue كلها تضيّق ما بعدها: كلٌّ منها يقتطع مساراً، فيُنقّي ما تبقّى.

أدوات التضييق — كلٌّ "مُثبِتٌ" يعرف المترجم دلالته

كل أداة هنا ليست قاعدة منفصلة تحفظها، بل تعبير JavaScript يعرف المترجم بأي مجموعة يربطه:

الأداةتفصلمثال
typeof x === "..."البدائيّات السبعة"string", "number", "boolean", "object", ...
فحص الصدق (truthiness)يطرح القيم الكاذبةif (x) يطرح null, undefined, 0, "", false, NaN
=== / !== بقيمة محدّدةيطرح/يثبت حرفيّةif (x === "yes")
inوجود خاصية في كائنif ("radius" in shape)
instanceofعضوية صنف (class)if (e instanceof Error)
Array.isArray(x)المصفوفاتيفصل T[] عن غيره

لاحظ نمطاً: كل أداة هي تعبير حقيقي يعمل وقت التشغيل (typeof فعلٌ في JavaScript). التضييق ليس بناءً خاصاً بـ TypeScript؛ إنه قراءة المترجم لمعنى كود JavaScript عادي. هذا أحد أعمق الجمال في التصميم: التحقّق وقت الترجمة يتزامن تماماً مع الفحص وقت التشغيل، لأن كليهما يستعملان نفس التعبير.

Discriminated Unions: ذروة الجمع بين الإقليم ٣ والإقليم ٤

الآن نوحّد كل ما بنيناه في أقوى نمطٍ نمذجة في اللغة. خذ كائنات لها حقلٌ "علامة" حرفية مشتركة:

typescript
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "rect"; w: number; h: number };

الحقل kind نوعه حرفيٌّ مختلف في كل عضو ("circle" vs "square" vs "rect"). هذه العلامة المُميِّزة (discriminant). فحصها يضيّق الاتحاد كلّه إلى عضوٍ واحد، لأن المترجم يعرف أن kind === "circle" تستبعد كل عضوٍ علامته ليست "circle":

typescript
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; // s هنا: عضو circle فقط case "square": return s.side ** 2; // s: square فقط case "rect": return s.w * s.h; // s: rect فقط } }

داخل case "circle"، الوصول s.radius آمن — رغم أن radius غير موجودة على بقية الأعضاء — لأن s ضُيِّقت لمجموعة عضوٍ واحد. هذا يحلّ، بأناقة وبسلامة كاملة، ما كان في JavaScript نمطاً هشّاً من if (obj.type === ...) بلا أي ضمان.

إغلاق دائرة never: التحقّق من الشمول

هنا يلتقي خيطان زرعناهما: never من الإقليم ٢، وهذا النمط. أضف الفرع الأخير:

typescript
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; case "square": return s.side ** 2; case "rect": return s.w * s.h; default: const _exhaustive: never = s; // ← الحارس throw new Error("unreachable"); } }

اشتقّ لماذا هذا عبقري، لا تحفظه: بعد معالجة كل الأعضاء الثلاثة، أي قيمةٍ تصل default؟ المجموعة أُفرِغت بالكامل عبر التضييق المتتالي، فنوع s هنا هو المجموعة الخالية = never. وإسناد never إلى never يصحّ. لكن لو أضاف زميلك غداً عضواً رابعاً { kind: "triangle"; ... } ونسي case له، فلن تُفرَغ المجموعة: يصل default نوعُ s = { kind: "triangle" }، وإسناده إلى never يُرفض (لأن {...} ⊄ ∅). فينفجر المترجم عند _exhaustive قائلاً "هذا ليس never".

ملاحظة

هذا هو معنى "نوعٌ مستحيل يصير حارساً" من الإقليم ٢. حوّلنا فراغ never إلى منبّهٍ آليّ يضمن أنك عالجت كل حالة — ويبقى يحرسك كلّما توسّع النموذج. لا اختبار، لا تذكّر بشري؛ المجموعات تحرس نفسها. هذا مثالٌ مثاليّ على "اشتقاق السلوك من العدسة" بدل حفظ وصفة.

حين تحتاج أن "تشهد" بنفسك: type predicates

أحياناً منطق التضييق معقّد لدرجة لا يستنتجها المترجم تلقائياً، فتغلّفه في دالة. المشكلة: دالة تُرجع boolean عاديّة لا تضيّق شيئاً عند المُستدعي. الحل: مُسنِد نوع (type predicate) بصيغة x is T:

typescript
function isString(x: unknown): x is string { return typeof x === "string"; } function use(v: unknown) { if (isString(v)) { v.toUpperCase(); // ✅ المترجم ضيّق v إلى string بناءً على "شهادة" الدالة } }

x is string تقول للمترجم: "إن أعادت هذه الدالة true، فاعتبر الوسيط في مجموعة string." انتبه للثمن: المترجم يصدّق شهادتك دون تحقّق — لو كذبت في جسم الدالة (أعدت true لقيمةٍ ليست نصاً)، انكسرت السلامة بصمت. المُسنِد نقطة ثقةٍ يدوية، أقرب الأقارب لـ any في الخطورة. استعمله حين يلزم، واحذر أن تكذب فيه.

(قريبٌ منه assertion functions بصيغة asserts x is T، التي تضيّق بعد استدعائها بدل if. اربطها ذهنياً بنفس الفكرة: أنت تُعلِم المُبرهِن بحقيقةٍ لا يستطيع برهانها وحده.)


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

(أ) اشتقّ نوع x عند كل موضع مُعلَّم، بلغة "تقاطع في الموجب، طرح في السالب":

typescript
function f(x: string | number | null | undefined) { if (x == null) return; // == تلتقط null و undefined معاً — لماذا؟ x; // (1) if (typeof x === "number") { x; // (2) } else { x; // (3) } }

سؤال إضافي على (1): لماذا == null (لا ===) يطرح null و undefined كليهما؟ ارجع لدلالة == في JavaScript. هذا مثالٌ على أن التضييق يعكس بدقّة معنى تعبير JS.

(ب) — الشمول. صمّم اتحاداً مميَّزاً Event بثلاثة أعضاء (مثلاً "click", "scroll", "key")، واكتب دالة تعالج كلّاً منها وتنتهي بحارس never. ثم أضف عضواً رابعاً ولا تعالجه، وراقب أين ومتى يشتكي المترجم بالضبط. اكتب بجملة: ما النوع الذي وصل الحارس بعد الإضافة، ولماذا رفضه never؟

(ج) — لغز التدفّق الخفي. تنبّأ، ثم جرّب:

typescript
function g(x: string | number) { let y = x; if (typeof x === "string") { x.toUpperCase(); // يمرّ؟ y.toUpperCase(); // يمرّ؟ ← انتبه: y نُسخت قبل الفحص } }

لماذا يضيِّق المترجم x ولا يضيّق y داخل نفس الـ if، رغم أن y === x قيميّاً؟ فكّر: التضييق يتتبّع تدفّق رمزٍ معيّن (x)، لا القيم المتساوية. ما الذي يعرفه المترجم عن x ولا يعرفه عن y بعد let y = x؟ (هذا يكشف حدود التحليل: يتبع الأسماء لا المساواة القيمية.)

(د) — type predicate يكذب. اكتب isEven(x: unknown): x is number تُرجع true فقط للأعداد الزوجية. الآن استعملها، واستدعِ x.toFixed() داخل الفرع الموجب. هل يقبل المترجم؟ والآن: مرّر لها النص "4" — هل يكتشف المترجم الكذبة؟ متى ينفجر البرنامج فعلياً؟ اكتب بجملة لماذا المُسنِد "أقرب الأقارب لـ any".


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

جعلنا النوع متحرّكاً مع منطق البرنامج:

  • التضييق = جبر مجموعات يقوده المنطق: تقاطع في الفرع الموجب، طرح في السالب، بحسب دلالة الفحص التي يعرفها المترجم عن JavaScript.
  • النوع صفةٌ للموضع لا للمتغيّر: المترجم يبني رسم تدفّق ويعيد حساب المجموعة عند كل عقدة؛ return/throw المبكر يضيّق ما بعده.
  • Discriminated unions = اتحاد حرفيّات (الإقليم ٣) + تضييق بالعلامة = أقوى نمط نمذجة، بسلامة كاملة.
  • حارس never يغلق دائرة الإقليم ٢: الفراغ يصير منبّه شمولٍ آلياً.
  • type predicates أداةٌ يدوية تُعلِم المُبرهِن بما لا يبرهنه وحده — بثمن الثقة العمياء.

ربطنا للوراء: استعملنا الاتحادات والحرفيّات (٣)، وnever والقاع (٢)، والعدسة الأساس (١). كل خيوط النصف الأول تجدّلت هنا في نمطٍ واحد عملي.

بذرة غموض

بذرة غموض: عاملنا الكائنات بسطحية حتى الآن ({ kind: "circle"; radius: number }). لكن الكائنات هي قلب JavaScript، ولها قصّة مجموعات خاصة وعجيبة: نوع الكائن لا يصف قيمةً واحدة بل **كل القيم التي لها على الأقل هذه الخصائص**. ولهذا، كائنٌ بخصائص زائدة يُقبل أحياناً... ويُرفض أحياناً، بتناقضٍ ظاهر سيدفعك للجنون حتى تفهم "فحص الخصائص الزائدة". ولماذا أصلاً TypeScript بنيوية لا اسمية مثل Java؟ للإقليم ٥.

التالي: 05-shapes.md

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