الوحدة 1 · الأنواع أداةً للتصميم
الـ Narrowing وتحليل تدفّق التحكّم
20 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
استخدم typeof والمساواة وin وinstanceof والعودة المبكرة لتضييق الأنواع الواسعة، وتعرّف على المواضع التي ينسى فيها TypeScript الـ narrowing كي تحافظ عليه.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
لطلب Cartwheel الحقل shippingFeeCents: number | null. القيمة null تعني أن الرسوم غير معروفة بعد (العنوان غير مكتمل). و0 تعني شحنًا مجانيًا. هذه هي التسمية المعروضة عند الدفع، كما كُتبت أول مرة:
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 نوع كل متغيّر عبر كل فرع وكل جملة عودة. يمكن أن يكون للاسم نفسه نوع مختلف في كل سطر:
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). كل فحص يقسم التدفّق إلى فروع، وفي كل فرع يكون نوع المتغيّر هو مجموعة القيم التي يمكن أن تبقى هناك. وحين يعود فرعٌ، لا يرى الكود بعده إلا ما تبقّى.
صندوق أدوات الـ 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:
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) أي شيء. فتضيّق النوع قبل أن تلمسه:
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 مما يحمله إلى داخله:
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، وهو لا يمكن أن يتغيّر أبدًا:
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[]:
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، فتضيق النتيجة.