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

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

أنواع Result للإخفاقات المتوقَّعة

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

الدرس في لمحة

أعِد الإخفاقات المتوقَّعة كقيم محدَّدة الأنواع عبر union من نوع Result، كي يُضطر المستدعون إلى معالجة البطاقة المرفوضة أو الكوبون المنتهي، واحتفظ بالاستثناءات للأخطاء البرمجية وما هو غير متوقَّع حقًا.

  1. 1جعل الإخفاق جزءًا من النوع
  2. 2أخطاء لها بنية
  3. 3تركيب الخطوات
  4. 4تغليف الكود الذي يرمي استثناءات
  5. 5أين ما زال للاستثناءات مكانها

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

إليك توقيع الدالة التي في قلب نظام الدفع في Cartwheel:

TypeScript
async function placeOrder(cart: Cart, payment: PaymentMethod): Promise<Order> { /* … */ }

يمكن أن تفشل بخمس طرق على الأقل يتوقّعها العمل كل يوم: البطاقة مرفوضة، أو منتج نفد من المخزون، أو الكوبون منتهي الصلاحية، أو العنوان لا يمكن التوصيل إليه، أو مزوّد الدفع يطلب 3-D Secure. التوقيع لا يذكر أيًّا منها. إنها استثناءات تُرمى من مكان ما في الداخل، وليس في TypeScript جملة throws. المستدعي الذي ينسى try/catch يمرّ في الترجمة بلا مشكلة، والعميل يرى شاشة فارغة.

جعل الإخفاق جزءًا من النوع

الإصلاح أن تُعيد الإخفاقات المتوقَّعة بدل رميها، باستخدام discriminated union تعرف كتابته أصلًا:

TypeScript
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 لا توجد إلا في عضو واحد:

TypeScript
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، كلٌّ منها بالبيانات اللازمة لمعالجته:

TypeScript
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.

تركيب الخطوات

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

TypeScript
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 لإخفاق تلك الخطوة يكون خطأ ترجمة، فلا يمكن لقائمة الإخفاقات أن تتقادم بصمت.

مخطّط على هيئة سكّة. ثلاث خطوات، validateCart وapplyCoupon وchargeCard، تقع على مسار نجاح يؤدّي إلى ok مع Order. ومن كل خطوة يهبط فرع إلى مسار إخفاق ينتهي بـ Result فيه ok يساوي false والـ union من CartError وCouponError وPaymentError. validateCart applyCoupon chargeCard ok(order) { ok: false, error: CartError | CouponError | PaymentError }
كل خطوة إمّا تستمر على مسار النجاح أو تخرج بخطئها

سترى مكتبات تقدّم methods مثل map وandThen لسلسلة النتائج بدلًا من ذلك. لا بأس بها، لكن في TypeScript يكون أسلوب العودة المبكرة عادةً أسهل قراءة ولا يحتاج إلى مكتبة: الـ narrowing يقوم بالعمل.

تغليف الكود الذي يرمي استثناءات

معظم الإخفاقات في نظام الدفع تبدأ حياتها كاستثناءات في كود شخص آخر: الـ SDK الخاص بالدفع يرمي CardError، وfetch تُرفض حين تنقطع الشبكة. حوّلها مرة واحدة، عند الحافة، إلى الـ Result الذي يتحدّثه كودك:

TypeScript
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 للأخطاء البرمجية والثوابت المكسورة وأعطال البنية التحتية التي لا يمكنك معالجتها محليًا.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

يمكن أن تفشل placeOrder(cart): Promise<Order> ببطاقة مرفوضة. ماذا يقول توقيعها للمستدعي عن ذلك؟

السؤال 2 من 4

لماذا يفشل السطر الأخير في الترجمة؟

TypeScript
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
declare const r: Result<Order, CheckoutError>;
console.log(r.value.id);
السؤال 3 من 4

أيّ إخفاق يناسب Result بدل الاستثناء؟

السؤال 4 من 4

تُعيد validateCart النوع Result<Cart, CartError> وتُعيد checkout النوع Result<Order, CartError | PaymentError>. هل يمرّ هذا في الترجمة؟

TypeScript
const cart = validateCart(input);
if (!cart.ok) return cart;

تدريب

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

أعِد الإخفاقات، لا ترمِها

حوّل خطوة الكوبون في Cartwheel إلى إخفاقات متوقَّعة محدَّدة الأنواع:

  1. عرّف Result<T, E> كـ discriminated union على ok، مع الدالتين المساعدتين ok(value) وerr(error).
  2. اجعل findCoupon تُعيد Result<Coupon, CouponError>: coupon-unknown حين لا يوجد الرمز، وcoupon-expired (مع expiredOn) حين يكون expiresOn قبل today.
  3. اجعل checkout تُعيد Result<PricedCart, CheckoutError>: افحص المخزون أولًا، ثم الكوبون (حين لا يكون couponCode هو null)، ثم أعِد السلة مع totalCents (المجموع الفرعي ناقص نسبة الكوبون، مقرّبًا).
  4. اجعل errorMessage تُعيد رسالة لكل نوع خطأ تتضمّن بياناته (الرمز، أو تاريخ الانتهاء، أو رمز المنتج SKU).
type Coupon = { code: string; percentOff: number; expiresOn: string };
type Cart = { lines: { sku: string; quantity: number; unitPriceCents: number }[] };
type PricedCart = Cart & { totalCents: number };

const COUPONS: Coupon[] = [
  { code: 'WELCOME10', percentOff: 10, expiresOn: '2099-12-31' },
  { code: 'SPRING15', percentOff: 15, expiresOn: '2025-05-31' },
];
const STOCK: Record<string, number> = { 'TENT-2P': 4, 'MUG-SET': 0 };

// 1. Define Result, ok and err.
type Result<T, E> = unknown;

type CouponError =
  | { type: 'coupon-unknown'; code: string }
  | { type: 'coupon-expired'; code: string; expiredOn: string };
type StockError = { type: 'out-of-stock'; sku: string; available: number };
type CheckoutError = CouponError | StockError;

// 2. Return a Result instead of throwing.
function findCoupon(code: string, today: string): Coupon {
  const coupon = COUPONS.find((c) => c.code === code);
  if (!coupon) throw new Error(`Unknown coupon ${code}`);
  if (coupon.expiresOn < today) throw new Error(`${code} expired on ${coupon.expiresOn}`);
  return coupon;
}

function checkStock(cart: Cart): Result<Cart, StockError> {
  for (const line of cart.lines) {
    const available = STOCK[line.sku] ?? 0;
    if (available < line.quantity) return { ok: false, error: { type: 'out-of-stock', sku: line.sku, available } };
  }
  return { ok: true, value: cart };
}

// 3. Compose: stock first, then the coupon (if any), then price the cart.
function checkout(cart: Cart, couponCode: string | null, today: string) {
  const subtotal = cart.lines.reduce((sum, l) => sum + l.quantity * l.unitPriceCents, 0);
  const coupon = couponCode ? findCoupon(couponCode, today) : null;
  const totalCents = coupon ? Math.round(subtotal * (1 - coupon.percentOff / 100)) : subtotal;
  return { ok: true, value: { ...cart, totalCents } };
}

// 4. One message per error type.
function errorMessage(e: CheckoutError): string {
  return 'Something went wrong';
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<ReturnType<typeof findCoupon>, Result<Coupon, CouponError>>>;
type T2 = Expect<Equal<ReturnType<typeof checkout>, Result<PricedCart, CheckoutError>>>;

function typeTests(r: Result<Coupon, CouponError>) {
  // @ts-expect-error: value is only available after checking ok
  r.value.code;
  if (r.ok) r.value.code;
}

const cart: Cart = { lines: [{ sku: 'TENT-2P', quantity: 1, unitPriceCents: 18900 }] };
const outcome = checkout(cart, 'SPRING15', '2026-10-05');
console.log(outcome.ok ? outcome.value.totalCents : errorMessage(outcome.error));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • الكوبون الصالح يخفّض المجموع (خصم 10% من 10000 يعطي 9000)
  • الكوبون المنتهي يُعيد إخفاق coupon-expired مع تاريخ انتهائه
  • يُفحص المخزون قبل الكوبون
  • errorMessage تتضمّن بيانات كل إخفاق