الوحدة 4 · أنماط لقواعد كود حقيقية
أنواع Result للإخفاقات المتوقَّعة
18 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
أعِد الإخفاقات المتوقَّعة كقيم محدَّدة الأنواع عبر union من نوع Result، كي يُضطر المستدعون إلى معالجة البطاقة المرفوضة أو الكوبون المنتهي، واحتفظ بالاستثناءات للأخطاء البرمجية وما هو غير متوقَّع حقًا.
- 1جعل الإخفاق جزءًا من النوع
- 2أخطاء لها بنية
- 3تركيب الخطوات
- 4تغليف الكود الذي يرمي استثناءات
- 5أين ما زال للاستثناءات مكانها
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
إليك توقيع الدالة التي في قلب نظام الدفع في Cartwheel:
async function placeOrder(cart: Cart, payment: PaymentMethod): Promise<Order> { /* … */ }يمكن أن تفشل بخمس طرق على الأقل يتوقّعها العمل كل يوم: البطاقة مرفوضة، أو منتج نفد من المخزون، أو الكوبون منتهي الصلاحية، أو العنوان لا يمكن التوصيل إليه، أو مزوّد الدفع يطلب 3-D Secure. التوقيع لا يذكر أيًّا منها. إنها استثناءات تُرمى من مكان ما في الداخل، وليس في TypeScript جملة throws. المستدعي الذي ينسى try/catch يمرّ في الترجمة بلا مشكلة، والعميل يرى شاشة فارغة.
جعل الإخفاق جزءًا من النوع
الإصلاح أن تُعيد الإخفاقات المتوقَّعة بدل رميها، باستخدام discriminated union تعرف كتابته أصلًا:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T>(value: T): { ok: true; value: T } => ({ ok: true, value });
const err = <E>(error: E): { ok: false; error: E } => ({ ok: false, error });ok هو المميِّز. لا يستطيع المستدعي قراءة value دون أن يثبت أولًا أن الاستدعاء نجح، لأن value لا توجد إلا في عضو واحد:
const result = await placeOrder(cart, payment);
showConfirmation(result.value);
// Error: Property 'value' does not exist on type 'Result<Order, CheckoutError>'.
// Property 'value' does not exist on type '{ ok: false; error: CheckoutError; }'.
if (result.ok) showConfirmation(result.value); // fineأخطاء لها بنية
يمكن أن يكون E نصًا، لكن النص في الأخطاء هو نظير status: string في الحالات. نمذج الإخفاقات كـ discriminated union، كلٌّ منها بالبيانات اللازمة لمعالجته:
type CheckoutError =
| { type: 'card-declined'; declineCode: string }
| { type: 'out-of-stock'; sku: string; available: number }
| { type: 'coupon-expired'; code: string; expiredOn: string };
function errorMessage(e: CheckoutError): string {
switch (e.type) {
case 'card-declined': return `Your card was declined (${e.declineCode}).`;
case 'out-of-stock': return `Only ${e.available} of ${e.sku} left.`;
case 'coupon-expired': return `${e.code} expired on ${e.expiredOn}.`;
}
}نوع الإرجاع : string يجعل هذا شاملًا، كما رأيت في القسم 1. وحين يضيف فريق المدفوعات 'requires-3ds' إلى الـ union، تُعلَّم كل دالة رسائل وكل فرع في الواجهة يتفرّع على type.
تركيب الخطوات
الدفع تسلسل: تحقّق من السلة، ثم طبّق الكوبون، ثم اخصم من البطاقة. كل خطوة يمكن أن تفشل بأخطائها الخاصة. والعودة المبكرة تركّبها دون تداخل:
type CartError = { type: 'out-of-stock'; sku: string; available: number };
type CouponError = { type: 'coupon-expired'; code: string; expiredOn: string };
async function checkout(input: CheckoutInput): Promise<Result<Order, CartError | CouponError | PaymentError>> {
const cart = validateCart(input.lines);
if (!cart.ok) return cart;
const priced = applyCoupon(cart.value, input.coupon);
if (!priced.ok) return priced;
const charge = await chargeCard(priced.value.total, input.payment);
if (!charge.ok) return charge;
return ok(createOrder(priced.value, charge.value));
}بعد if (!cart.ok)، يضيق cart إلى { ok: false; error: CartError }، وهو قابل للإسناد إلى نوع الإخفاق الأوسع للدالة، فيمكن إعادته كما هو. ومسار النجاح يُقرأ من الأعلى إلى الأسفل، ونوع الإرجاع يوثّق كل إخفاق على المستدعي معالجته. وإن أضاف أحدهم لاحقًا خطوة رابعة بنوع خطأ جديد ونسي توسيع نوع الإرجاع، فإن return لإخفاق تلك الخطوة يكون خطأ ترجمة، فلا يمكن لقائمة الإخفاقات أن تتقادم بصمت.
سترى مكتبات تقدّم methods مثل map وandThen لسلسلة النتائج بدلًا من ذلك. لا بأس بها، لكن في TypeScript يكون أسلوب العودة المبكرة عادةً أسهل قراءة ولا يحتاج إلى مكتبة: الـ narrowing يقوم بالعمل.
تغليف الكود الذي يرمي استثناءات
معظم الإخفاقات في نظام الدفع تبدأ حياتها كاستثناءات في كود شخص آخر: الـ SDK الخاص بالدفع يرمي CardError، وfetch تُرفض حين تنقطع الشبكة. حوّلها مرة واحدة، عند الحافة، إلى الـ Result الذي يتحدّثه كودك:
type PaymentError = { type: 'card-declined'; declineCode: string };
async function chargeCard(amount: Money, payment: PaymentMethod): Promise<Result<Receipt, PaymentError>> {
try {
return ok(await paymentSdk.charge(amount, payment));
} catch (e: unknown) {
if (e instanceof CardError) return err({ type: 'card-declined', declineCode: e.code });
throw e; // not a failure we understand: let it propagate
}
}ثلاثة أمور تستحق التقليد. متغيّر catch من نوع unknown، كما هو دائمًا تحت strict، فتضيّقه بـ instanceof قبل قراءة code. ولا يتحوّل إلى قيمة إلا الإخفاق الوحيد الذي يتوقّعه العمل؛ وكل ما عداه يُعاد رميه دون مساس، مع الـ stack trace كاملًا. ونوع إرجاع الدالة صار الآن قائمة صادقة بما يمكن أن يسوء، يراها كل مستدعٍ في التلميح.
النتائج غير المتزامنة تتداخل بالطريقة البديهية، Promise<Result<T, E>>. الـ promise المرفوض ما زال يعني “حدث شيء غير متوقَّع”، و{ ok: false } المحلول يعني “العمل قال لا”. وإبقاء هاتين القناتين منفصلتين هو الغاية كلها.
أين ما زال للاستثناءات مكانها
Result للإخفاقات التي يتوقّعها العمل ويستطيع المستدعي التصرّف بناءً عليها. إنه ليس بديلًا عن كل throw:
- الأخطاء البرمجية والثوابت المكسورة (
nullحيث تقول الأنواع إنه لا يمكن وجوده، أو عضو union غير مُعالَج يصل إلىassertNever) يجب أن ترمي استثناءً. لا يوجد ما هو معقول ليفعله المستدعي، وأنت تريد الـ stack trace. - أعطال البنية التحتية التي لا تستطيع معالجتها محليًا (قاعدة البيانات متوقّفة) يمكن أن تنتشر إلى حدّ أخطاء واحد أو طبقة إعادة محاولة.
- كود الأطراف الثالثة الذي يرمي استثناءات يُغلَّف مرة واحدة عند الحدود، فتتحوّل الاستثناءات التي تتوقّعها إلى
err(…)ويُعاد رمي الباقي.
في التمرين ستبني خطوة الكوبون في Cartwheel بـ Result، وتركّبها في دالة دفع، وتكتب رسائل الأخطاء الشاملة. والدرس القادم يتناول البيانات التي تبدأ منها تلك الخطوات: المدخلات غير الموثوقة.
أهم النقاط
- توقيع الدالة لا يقول شيئًا عمّا ترميه، لذا يسهل على المستدعين نسيان الإخفاقات المتوقَّعة المخبّأة في الاستثناءات.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }هو discriminated union، فعلى المستدعين فحصokقبل أن يستطيعوا قراءةvalue.- نمذج الأخطاء أيضًا كـ discriminated union، كي يحمل كل إخفاق بياناته الخاصة وتستطيع الواجهة معالجة كل حالة بشمول.
- ركّب النتائج بالعودة المبكرة (
if (!r.ok) return r;)؛ فالإخفاق المضيَّق قابل للإسناد إلى union الأخطاء الأوسع لدى المستدعي. - استخدم
Resultللإخفاقات التي يتوقّعها العمل؛ واحتفظ بـthrowللأخطاء البرمجية والثوابت المكسورة وأعطال البنية التحتية التي لا يمكنك معالجتها محليًا.