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

الوحدة 4 · أنماط لقواعد كود حقيقية

الـ Branded Types والفحوص الشاملة

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

الدرس في لمحة

امنح القيم المتشابهة في الشكل أنواعًا مختلفة بالوسوم والمُنشئات الذكية، وأوقف خلط العملات وقت الترجمة، واجعل كل switch على union شاملًا بشكل مُثبَت باستخدام never.

  1. 1الوسوم
  2. 2المُنشئات الذكية
  3. 3مال يرفض الاختلاط
  4. 4الشمول باستخدام never

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

خطأ الاسترداد من الدرس الأول كان سببه رقمين يبدوان متشابهين: الدولارات والسنتات. ولدى Cartwheel المشكلة نفسها في كل مكان. معرّف الطلب ومعرّف العميل كلاهما number. والمبلغ بالسنتات والكمية كلاهما number. والتنميط البنيوي، الذي تعرّفت عليه في الـ Structural Typing وقابلية الإسناد، يعتبر الأشياء المتشابهة في الشكل متطابقة، فيدعك بسرور تكتب cancelOrder(customer.id).

الإصلاح أن تجعل الأشكال مختلفة، دون تغيير أي شيء وقت التشغيل.

الوسوم

الـ branded type (النوع الموسوم) هو تقاطع (intersection) بين نوع أولي وخاصية لا وجود لها إلا في نظام الأنواع:

TypeScript
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 مكانه:

TypeScript
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) الذي يتحقّق ثم يحوّل:

TypeScript
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 في أي مكان من النظام معروف أنه معرّف صالح. الوسم إيصال: يثبت أن القيمة مرّت بالفحص.

رقم عادي قادم من رابط أو JSON يمرّ عبر المُنشئ الذكي toOrderId، الذي يتحقّق منه فإمّا أن يرمي خطأً أو يُعيد OrderId. الدوال مثل cancelOrder لا تقبل إلا OrderId، فلا يستطيع الرقم العادي الوصول إليها مباشرة؛ ذلك المسار مسدود. number من URL أو JSON toOrderId(n) تحقّق، ثم حوّل cancelOrder(id) تأخذ OrderId المسار المباشر: خطأ ترجمة
المُنشئات الذكية هي البوابة الوحيدة إلى النوع الموسوم

مال يرفض الاختلاط

وسم السنتات نصف المهمة. والنصف الآخر هو العملة. اجعل العملة معامل نوع، فيصبح Money<'USD'> وMoney<'EUR'> نوعين مختلفين:

TypeScript
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 يجعل ذلك صريحًا، ويعمل حتى حين لا تُعيد الدالة شيئًا:

TypeScript
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 المنسي إلى خطأ ترجمة.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

type OrderId = number & { readonly [brand]: 'OrderId' }. كيف تبدو خاصية الوسم وقت التشغيل؟

السؤال 2 من 4

أين يجب أن تظهر as OrderId في قاعدة كود جيدة التصميم؟

السؤال 3 من 4

تُستدعى add<C extends Currency>(a: Money<C>, b: Money<C>) هكذا: add(usd, eur). ماذا يحدث؟

السؤال 4 من 4

أُضيفت حالة جديدة 'on-hold'. أيّ دالة تفشل في الترجمة إلى أن تتعامل معها؟

TypeScript
function label(s: OrderStatus): string {
  switch (s) {
    case 'processing': return 'Packing';
    // …one case for every other existing status…
    default: return assertNever(s);
  }
}

تدريب

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

سِم المعرّفات، واقفل العملة

قوِّ أنواع الدفع في Cartwheel:

  1. سِم OrderId وCustomerId وCents بحيث تتمايز عن number وعن بعضها، واجعل المُنشئات الذكية الثلاثة تُعيد قيمًا موسومة.
  2. اجعل add ترفض قيم Money بعملات مختلفة.
  3. اجعل describePayment تتعامل مع cash-on-delivery (تُعيد 'Cash on delivery') وأضف فحص شمول يفشل في الترجمة إن تُرك نوع دفع دون معالجة يومًا ما، ويرمي خطأً وقت التشغيل مع نوع مجهول.
// Brand these three so they stop being interchangeable numbers.
type OrderId = number;
type CustomerId = number;
type Cents = number;

function toOrderId(n: number): OrderId {
  if (!Number.isInteger(n) || n < 1000) throw new Error(`Invalid order id: ${n}`);
  return n;
}

function toCustomerId(n: number): CustomerId {
  if (!Number.isInteger(n) || n < 1) throw new Error(`Invalid customer id: ${n}`);
  return n;
}

function toCents(n: number): Cents {
  if (!Number.isSafeInteger(n)) throw new Error(`Cents must be a whole number, got ${n}`);
  return n;
}

type Currency = 'USD' | 'EUR' | 'EGP';
type Money<C extends Currency = Currency> = { cents: Cents; currency: C };

function money<C extends Currency>(cents: number, currency: C): Money<C> {
  return { cents: toCents(cents), currency };
}

function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return { cents: toCents(a.cents + b.cents), currency: a.currency };
}

type PaymentMethod =
  | { kind: 'card'; last4: string }
  | { kind: 'wallet'; provider: 'apple' | 'google' }
  | { kind: 'cash-on-delivery' };

function describePayment(p: PaymentMethod): string {
  switch (p.kind) {
    case 'card': return `Card ending ${p.last4}`;
    case 'wallet': return `${p.provider === 'apple' ? 'Apple' : 'Google'} wallet`;
  }
}

// ---- Type tests (do not edit) ----
type T1 = Expect<NotEqual<OrderId, number>>;
type T2 = Expect<NotEqual<OrderId, CustomerId>>;
type T3 = Expect<Equal<OrderId extends number ? true : false, true>>;

function typeTests(customer: CustomerId, usd: Money<'USD'>, eur: Money<'EUR'>) {
  // @ts-expect-error: a customer id is not an order id
  const fromCustomer: OrderId = customer;
  // @ts-expect-error: plain numbers must go through toOrderId
  const fromNumber: OrderId = 1042;
  // @ts-expect-error: dollars and euros cannot be added
  add(usd, eur);
  const total = add(usd, usd);
  type T4 = Expect<Equal<typeof total, Money<'USD'>>>;
}

console.log(add(money(18900, 'USD'), money(499, 'USD')), describePayment({ kind: 'cash-on-delivery' }));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • toOrderId ترفض 999 وتقبل 1042
  • add تجمع مبلغين بالعملة نفسها
  • describePayment تتعامل مع الدفع عند الاستلام
  • describePayment ترمي خطأً مع نوع دفع مجهول