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

الوحدة 1 · الأنواع أداةً للتصميم

الـ Narrowing وتحليل تدفّق التحكّم

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

الدرس في لمحة

استخدم typeof والمساواة وin وinstanceof والعودة المبكرة لتضييق الأنواع الواسعة، وتعرّف على المواضع التي ينسى فيها TypeScript الـ narrowing كي تحافظ عليه.

  1. 1تحليل تدفّق التحكّم
  2. 2صندوق أدوات الـ narrowing
  3. 3أين يضيع الـ narrowing
  4. 4تضييق المصفوفات بـ filter

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

لطلب Cartwheel الحقل shippingFeeCents: number | null. القيمة null تعني أن الرسوم غير معروفة بعد (العنوان غير مكتمل). و0 تعني شحنًا مجانيًا. هذه هي التسمية المعروضة عند الدفع، كما كُتبت أول مرة:

TypeScript
function shippingLabel(feeCents: number | null): string {
  if (!feeCents) return 'Calculated at checkout';
  if (feeCents === 0) return 'Free';
  return `${(feeCents / 100).toFixed(2)}`;
}

كل عميل شحنه مجاني رأى “Calculated at checkout”. لم يشتكِ TypeScript، لأن feeCents بعد السطر الأول من نوع number، ومقارنة رقم بـ 0 أمر مشروع تمامًا. الأنواع كانت صحيحة؛ الـ narrowing (تضييق النوع) هو الخاطئ. هذا الدرس عن كيفية تضييق TypeScript للأنواع، كي تجعله يعمل لصالحك بدل أن تثق به ثقة عمياء.

تحليل تدفّق التحكّم

يتتبّع TypeScript نوع كل متغيّر عبر كل فرع وكل جملة عودة. يمكن أن يكون للاسم نفسه نوع مختلف في كل سطر:

TypeScript
function shippingLabel(feeCents: number | null): string {
  // feeCents: number | null
  if (feeCents === null) return 'Calculated at checkout';
  // feeCents: number
  if (feeCents === 0) return 'Free';
  // feeCents: number
  return `${(feeCents / 100).toFixed(2)}`;
}

هذا هو تحليل تدفّق التحكّم (control-flow analysis). كل فحص يقسم التدفّق إلى فروع، وفي كل فرع يكون نوع المتغيّر هو مجموعة القيم التي يمكن أن تبقى هناك. وحين يعود فرعٌ، لا يرى الكود بعده إلا ما تبقّى.

تدفّق التحكّم في shippingLabel. يبدأ المعامل بنوع number أو null. الفحص feeCents يساوي null يتفرّع إلى جملة عودة نوعها null. المسار المتبقّي نوعه number، ثم الفحص feeCents يساوي 0 يتفرّع إلى جملة عودة للشحن المجاني، والمسار الأخير رسوم موجبة من نوع number. number | null feeCents === null ? نعم null → "Calculated…" لا number: === 0 ? نعم 0 → "Free" number → "$4.99"
كل فحص يقسم التدفّق؛ والنوع بعده هو ما يمكن أن يبقى

صندوق أدوات الـ narrowing

تضيّق الأنواع بفحوص JavaScript عادية؛ ويفهم TypeScript كل واحد منها:

الفحص ما يضيّقه مثال
typeof x === 'string' الأنواع الأولية typeof amount === 'number'
x === null، x !== undefined القيم الحرفية coupon !== null
'prop' in x unions الكائنات حسب الخاصية 'trackingNumber' in shipment
x instanceof C نُسخ الأصناف err instanceof TypeError
Array.isArray(x) المصفوفات Array.isArray(input)

فحص الصدقية (truthiness)، أي if (x)، يضيّق النوع أيضًا، وهو الفحص الذي يجب أن تحذر منه. فهو يُزيل null وundefined، لكنه يُزيل أيضًا 0 و'' وNaN وfalse. مع كائن أو مصفوفة، فحص الصدقية مقبول. أما مع رقم أو نص يعني فيه الصفر أو الفراغ شيئًا، فقارن مع null صراحةً.

in وinstanceof عمليًا

فحصان يستحقان نظرة أقرب لأن عميل الدفع يستخدمهما باستمرار. يجيب مزوّد الدفع بأحد شكلين، وواحد منهما فقط فيه receiptId:

TypeScript
type PaymentReply = { receiptId: string; capturedCents: number } | { declineCode: string };

function summarize(reply: PaymentReply): string {
  if ('receiptId' in reply) return `Paid, receipt ${reply.receiptId}`;
  return `Declined (${reply.declineCode})`;
}

'receiptId' in reply تُبقي فقط أعضاء الـ union التي تعرّف receiptId. هذا يعمل، لكنه يضيّق النوع بناءً على غياب حقل في الأعضاء الأخرى، فإضافة receiptId? إلى شكل الرفض لاحقًا ستكسره بصمت. حين تتحكّم في الأنواع، يكون المميِّز الحرفي (الدرس القادم) أمتن.

وinstanceof هي أداة الأخطاء. تحت strict، يكون المتغيّر في جملة catch من نوع unknown، لأن JavaScript تسمح لك برمي (throw) أي شيء. فتضيّق النوع قبل أن تلمسه:

TypeScript
function describeFailure(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === 'string') return err;
  return 'Unknown error';
}

أين يضيع الـ narrowing

الـ narrowing حقيقةٌ عن نقطة واحدة في التدفّق. والـ callback يُنفَّذ في وقت آخر، لذا يحذر TypeScript مما يحمله إلى داخله:

TypeScript
type Order = { id: number; couponCode: string | null };

function couponBanner(order: Order, names: string[]): string[] {
  if (order.couponCode === null) return [];
  return names.map((n) => `${n}, ${order.couponCode.toUpperCase()} is applied`);
  // Error: 'order.couponCode' is possibly 'null'.
}

خارج الدالة السهمية، order.couponCode من نوع string. وداخلها يختفي الـ narrowing، لأن couponCode خاصية قابلة للتغيير وقد يعيدها شيءٌ إلى null قبل أن تستدعي map الـ callback. والإصلاح هو التقاط القيمة المضيَّقة في const، وهو لا يمكن أن يتغيّر أبدًا:

TypeScript
function couponBanner(order: Order, names: string[]): string[] {
  const code = order.couponCode;
  if (code === null) return [];
  return names.map((n) => `${n}, ${code.toUpperCase()} is applied`);
}

منذ TypeScript 5.4، تحتفظ المعاملات ومتغيّرات let أيضًا بتضييقها داخل الـ callbacks، ما دام لا يُعاد الإسناد إليها بعد إنشاء الـ callback. أما الخصائص فلا تحتفظ به أبدًا. النسخ إلى const يعمل في كل الإصدارات ويجعل النية واضحة.

تضييق المصفوفات بـ filter

كانت filter مصدر إحباط كلاسيكيًا: coupons.filter((c) => c !== null) كان نوعها يبقى (string | null)[]. منذ TypeScript 5.5، يستنتج TypeScript الـ type predicate من الدوال السهمية البسيطة مثل هذه، فتكون النتيجة string[]:

TypeScript
const coupons: (string | null)[] = ['WELCOME10', null, 'LOYAL5'];
const codes = coupons.filter((c) => c !== null); // string[]

لا يحدث الاستنتاج إلا حين يثبت الفحصُ النوعَ في الاتجاهين. (c) => !!c لا تُستنتج على أنها predicate: فالنتيجة الكاذبة قد تعني '' لا null، فلا يستطيع TypeScript أن يستنتج أن القيمة المرفوضة هي null. والأمر نفسه مع filter(Boolean). اكتب المقارنة الصريحة وستحصل على الـ narrowing وعلى التعامل الصحيح مع النصوص الفارغة معًا.

التمرين أدناه يجمع الأخطاء الثلاثة من هذا الدرس في ملف واحد. وفي الدرس التالي ستصمّم أنواعًا تجعل الـ narrowing شبه تلقائي: الـ discriminated unions.

أهم النقاط

  • يمنح تحليل تدفّق التحكّم المتغيّرَ نوعًا مختلفًا عند كل نقطة في الكود، بناءً على الفحوص وجمل العودة التي سبقتها.
  • فحوص الصدقية (truthiness) تُزيل أيضًا 0 و''، لذا قارن مع null أو undefined صراحةً حين تكون لهاتين القيمتين دلالة.
  • يسقط الـ narrowing لخاصية قابلة للتغيير داخل الـ callbacks؛ انسخ القيمة إلى const قبل الـ callback لتحافظ عليه.
  • منذ TypeScript 5.5، تُستنتج الدالة السهمية البسيطة مثل (c) => c !== null الممرَّرة إلى filter على أنها type predicate، فتضيق النتيجة.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

قيمة shippingFeeCents هي null حين لا تكون الرسوم معروفة بعد، و0 للشحن المجاني. ماذا تُعيد هذه الدالة لطلب شحنه مجاني؟

TypeScript
function label(fee: number | null) {
  if (!fee) return 'Calculated at checkout';
  return fee === 0 ? 'Free' : `${fee / 100}`;
}
السؤال 2 من 4

لماذا يكون order.coupon خطأً داخل الـ callback؟

TypeScript
if (order.coupon !== null) {
  names.map((n) => `${n}: ${order.coupon.toUpperCase()}`);
}
السؤال 3 من 4

في TypeScript 5.9، ما نوع codes؟

السؤال 4 من 4

أيّ فحص يضيّق input: Order | Order[] إلى Order[]؟

تدريب

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

أصلح ثلاثة أخطاء في الـ narrowing

في هذا الملف مشكلات الـ narrowing الثلاث من الدرس. أصلحها دون استخدام تأكيد عدم الفراغ ! (non-null assertion) أو as أو any:

  1. يجب أن تُعيد shippingLabel القيمة 'Free' حين تكون shippingFeeCents تساوي 0، و'Calculated at checkout' حين تكون null، ومبلغًا بالدولار مثل '$4.99' في غير ذلك.
  2. يجب أن تُعيد usedCoupons مصفوفة string[] حقيقية من رموز الكوبونات غير الفارغة.
  3. يجب أن تمرّ couponBanner في الترجمة وأن تبقى تحوّل الرمز إلى أحرف كبيرة داخل الـ callback.
type Order = {
  id: number;
  couponCode: string | null;
  // null = not known yet (address incomplete); 0 = free shipping
  shippingFeeCents: number | null;
};

function shippingLabel(order: Order): string {
  if (!order.shippingFeeCents) return 'Calculated at checkout';
  if (order.shippingFeeCents === 0) return 'Free';
  return `${(order.shippingFeeCents / 100).toFixed(2)}`;
}

function usedCoupons(orders: Order[]): string[] {
  return orders.map((o) => o.couponCode).filter((c) => c);
}

function couponBanner(order: Order, names: string[]): string[] {
  if (order.couponCode === null) return [];
  return names.map((n) => `${n}, ${order.couponCode.toUpperCase()} is applied`);
}

const orders: Order[] = [
  { id: 1001, couponCode: 'welcome10', shippingFeeCents: 0 },
  { id: 1002, couponCode: null, shippingFeeCents: null },
  { id: 1003, couponCode: 'loyal5', shippingFeeCents: 499 },
];
console.log(orders.map(shippingLabel), usedCoupons(orders));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ
  • shippingLabel تُعيد ‘Free’ مع 0، و’Calculated at checkout’ مع null، و’$4.99’ مع 499
  • usedCoupons تُبقي الرموز غير الفارغة فقط، بالترتيب نفسه
  • couponBanner تحوّل الرمز إلى أحرف كبيرة وتُعيد [] حين لا يوجد كوبون