الوحدة 4 · أنماط لقواعد كود حقيقية
الـ Branded Types والفحوص الشاملة
19 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
امنح القيم المتشابهة في الشكل أنواعًا مختلفة بالوسوم والمُنشئات الذكية، وأوقف خلط العملات وقت الترجمة، واجعل كل switch على union شاملًا بشكل مُثبَت باستخدام never.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
خطأ الاسترداد من الدرس الأول كان سببه رقمين يبدوان متشابهين: الدولارات والسنتات. ولدى Cartwheel المشكلة نفسها في كل مكان. معرّف الطلب ومعرّف العميل كلاهما number. والمبلغ بالسنتات والكمية كلاهما number. والتنميط البنيوي، الذي تعرّفت عليه في الـ Structural Typing وقابلية الإسناد، يعتبر الأشياء المتشابهة في الشكل متطابقة، فيدعك بسرور تكتب cancelOrder(customer.id).
الإصلاح أن تجعل الأشكال مختلفة، دون تغيير أي شيء وقت التشغيل.
الوسوم
الـ branded type (النوع الموسوم) هو تقاطع (intersection) بين نوع أولي وخاصية لا وجود لها إلا في نظام الأنواع:
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type OrderId = Brand<number, 'OrderId'>;
type CustomerId = Brand<number, 'CustomerId'>;
type Cents = Brand<number, 'Cents'>;declare const brand: unique symbol تعلن symbol لا يوجد أبدًا وقت التشغيل؛ واستخدامه مفتاحًا للخاصية يعني أنه لا يمكن لأي كائن حقيقي أن يملكه عن طريق الخطأ، ولا يستطيع أي شيء خارج هذا الملف تزويره بالاسم. يبقى OrderId رقمًا لكل الأغراض (يمكنك مقارنته وطباعته وإرساله كـ JSON)، لكن TypeScript لم يعد يقبل رقمًا عاديًا أو CustomerId مكانه:
function cancelOrder(id: OrderId) {}
cancelOrder(customer.id);
// Error: Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId'.
cancelOrder(1042);
// Error: Argument of type 'number' is not assignable to parameter of type 'OrderId'.المُنشئات الذكية
إن لم يكن الرقم العادي OrderId، فمن أين تأتي قيم OrderId؟ من دالة واحدة بالضبط لكل وسم، هي المُنشئ الذكي (smart constructor) الذي يتحقّق ثم يحوّل:
function toOrderId(n: number): OrderId {
if (!Number.isInteger(n) || n < 1000) throw new Error(`Invalid order id: ${n}`);
return n as OrderId;
}
function toCents(n: number): Cents {
if (!Number.isSafeInteger(n)) throw new Error(`Cents must be a whole number, got ${n}`);
return n as Cents;
}هذا هو المكان المشروع الوحيد لـ as في الكود الموسوم. ولأن التحويل لا يحدث إلا بعد التحقّق، فإن كل OrderId في أي مكان من النظام معروف أنه معرّف صالح. الوسم إيصال: يثبت أن القيمة مرّت بالفحص.
مال يرفض الاختلاط
وسم السنتات نصف المهمة. والنصف الآخر هو العملة. اجعل العملة معامل نوع، فيصبح Money<'USD'> وMoney<'EUR'> نوعين مختلفين:
type Currency = 'EGP' | 'AED' | 'SAR' | 'JOD' | 'GBP' | 'EUR' | 'USD' | 'CAD';
type Money<C extends Currency = Currency> = { cents: Cents; currency: C };
function add<C extends Currency>(a: Money<C>, b: Money<NoInfer<C>>): Money<C> {
return { cents: toCents(a.cents + b.cents), currency: a.currency };
}
add(usdPrice, usdShipping); // Money<'USD'>
add(usdPrice, eurShipping);
// Error: Argument of type 'Money<"EUR">' is not assignable to parameter of type 'Money<"USD">'.تفصيلان مهمّان. دون NoInfer، سيكون كلا الوسيطين موقع استنتاج وسيصبح C بهدوء 'USD' | 'EUR'، وهو بالضبط الفخ من التحكّم في الـ Inference. وa.cents + b.cents هي number عادية: الحساب ينزع الوسوم، لأن TypeScript لا يعرف إن كانت النتيجة ما زالت تعني “سنتات”. أعِد الوسم عن قصد، عبر المُنشئ، كي تُفحص قاعدة “السنتات أعداد صحيحة” من جديد.
أين تؤتي الوسوم ثمارها
لا تسِم كل شيء. كل وسم يضيف استدعاء مُنشئ أينما دخلت القيم إلى النظام، وعلى بيانات الاختبار الجاهزة أن تمرّ به أيضًا. سِم حيث تتشارك قيمتان النوع الأولي نفسه، ويسهل الخلط بينهما، ويكون الخلط مكلفًا: معرّفات الكيانات المختلفة، والمبالغ بوحدات مختلفة، والنصوص التي يجب أن تجتاز تحقّقًا قبل الاستخدام، مثل Email أو Sku. أما quantity التي لا تُضرب إلا في سعر فلا تحتاج وسمًا. في قاعدة كود كبيرة، نحو اثني عشر وسمًا مختارًا جيدًا يلتقط معظم أخطاء “النوع الصحيح، المعنى الخاطئ”؛ ومئة منها لا تولّد في الغالب إلا التحويلات.
الشمول باستخدام never
الوسوم تجعل القيم متمايزة. والنمط اليومي الآخر يجعل المعالجة كاملة. رأيت في الـ Discriminated Unions لحالات المجال أن جملة عودة ناقصة قد تكشف حالة منسيّة. فحص never يجعل ذلك صريحًا، ويعمل حتى حين لا تُعيد الدالة شيئًا:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}
function notifyCustomer(order: Order): void {
switch (order.status) {
case 'processing': return sendEmail('We are packing your order');
case 'shipped': return sendEmail(`Track it: ${order.trackingNumber}`);
case 'delivered': return sendEmail('Enjoy!');
case 'cancelled': return sendEmail(`Cancelled: ${order.cancelReason}`);
case 'returned': return sendEmail('Refund on its way');
default: return assertNever(order);
}
}في فرع default، يكون كل عضو مُعالَج قد ضُيّق وأُزيل، فيكون order من نوع never، ويمرّ تمريره إلى معامل never في الترجمة. أضف حالة 'on-hold' فيصبح order في ذلك الفرع هو شكل on-hold، وهو غير قابل للإسناد إلى never: خطأ ترجمة يشير إلى جملة الـ switch التي يجب تحديثها بالضبط. ووقت التشغيل، إن تسلّلت بيانات سيئة متجاوزةً الأنواع، ترمي الدالة خطأً برسالة واضحة بدل أن لا تفعل شيئًا.
إن كنت تفضّل ألّا تعرّف دالة مساعدة، فإن default: throw new Error(`Unhandled: ${order satisfies never}`) تُجري الفحص نفسه مباشرة. ولجداول البحث، يعطيك Record<OrderStatus, string> مع satisfies الشمول مجانًا: المفتاح الناقص خطأ ترجمة.
في التمرين ستسِم معرّفات Cartwheel وسنتاته، وتجعل add ترفض العملات المختلطة، وتضيف فحص شمول. والدرس القادم يتناول الشيء الآخر الذي يجب أن يتعامل معه كل نظام دفع: الإخفاقات المتوقَّعة.
أهم النقاط
- الوسم (brand) تقاطعٌ بين نوع أولي وخاصية فريدة لا وجود لها إلا في نظام الأنواع، فيتوقّف
OrderIdوCustomerIdعن التبادل رغم أن كليهما رقم. - أنشئ القيم الموسومة فقط في مُنشئات ذكية تتحقّق أولًا؛ ذلك هو المكان الوحيد الذي يليق فيه تحويل
as. - الحساب على الأرقام الموسومة يُعيد
numberعاديًا، لذا يجب أن تعيد دوال المال وسم نتائجها عن قصد. Money<NoInfer<C>>على الوسيط الثاني يمنعadd(usd, eur)من استنتاج union يخلط العملات.- فرع
defaultيمرّر القيمة إلى معامل من نوعnever، أو يفحصها بـsatisfies never، يحوّل عضو الـ union المنسي إلى خطأ ترجمة.