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

الوحدة 5 · إيصال TypeScript إلى الإنتاج

تصريحات using والـ Decorators القياسية

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

الدرس في لمحة

حرّر الموارد بموثوقية باستخدام تصريحات using وSymbol.dispose، واكتب decorators قياسية محدَّدة الأنواع للسلوك العابر للأجزاء مثل التسجيل وإعادة المحاولة.

  1. 1using: تنظيف مربوط بالنطاق
  2. 2الـ decorators القياسية

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

يحجز نظام الدفع في Cartwheel المخزون قبل الخصم من البطاقة، ثم يحرّر الحجز إن فشل أي شيء. النسخة الأولى من ذلك الكود بدت هكذا:

TypeScript
function placeOrder(skus: string[]) {
  const reservations = skus.map((sku) => reserve(sku));
  chargeCard(); // throws on a declined card
  reservations.forEach((r) => r.release());
}

البطاقة المرفوضة تتخطّى السطر الأخير، فيبقى المخزون مقفلًا حتى تلاحظه مهمة ليلية. الإصلاح التقليدي هو try/finally، متداخلة مرة لكل مورد. هذا الدرس يغطّي جواب اللغة المدمج، ثم ميزة حديثة ثانية تُبقي الكود العابر للأجزاء مثل إعادة المحاولة خارج منطق العمل: الـ decorators القياسية.

using: تنظيف مربوط بالنطاق

تصريح using مثل const، مع إضافة واحدة: حين تنتهي الكتلة التي يعيش فيها، بـ return أو بالوصول إلى نهايتها أو بـ throw، تستدعي JavaScript الـ method المسمّاة [Symbol.dispose]() على القيمة.

TypeScript
function reserve(sku: string): Disposable {
  log(`reserve ${sku}`);
  return { [Symbol.dispose]: () => log(`release ${sku}`) };
}

function placeOrder() {
  using tent = reserve('TENT-2P');
  using mug = reserve('MUG-SET');
  chargeCard(); // even if this throws…
}               // …both are released here: MUG-SET first, then TENT-2P

تصريحات using المتعددة تُحرَّر بترتيب معكوس، تمامًا مثل كتل finally المتداخلة، فيُحرَّر المورد دائمًا قبل الموارد التي أُنشئ بعدها. وإن رمى التحرير نفسه خطأً بينما خطأ آخر قيد الانتشار، يُحفظ الاثنان في SuppressedError بدل أن يحلّ أحدهما محلّ الآخر بصمت.

للتنظيف غير المتزامن، مثل إغلاق معاملة قاعدة بيانات، استخدم await using مع كائن فيه [Symbol.asyncDispose]() تُعيد promise. وحين يكون عدد الموارد ديناميكيًا، اجمعها في DisposableStack (أو AsyncDisposableStack)، وهي نفسها قابلة للتحرير:

TypeScript
function placeOrder(skus: string[]) {
  using stack = new DisposableStack();
  for (const sku of skus) stack.use(reserve(sku));
  chargeCard();
} // every reservation released, in reverse order

وصلت using في TypeScript 5.2. وهي تعمل أصليًا في Node 24 وفي Chrome وEdge منذ الإصدار 134 وFirefox منذ 141، لكن Safari لا يدعمها بعد (حتى أواخر 2026)، فهي ليست Baseline. وللكود الذي يصل إلى المتصفّحات، دع TypeScript أو الـ bundler يحوّل الصياغة إلى صيغة أقدم، وتأكّد من وجود Symbol.dispose في بيئة التشغيل (يوفّره polyfill حيث يغيب). والأنواع موجودة في الـ lib المسمّاة esnext.disposable (المضمّنة في "lib": ["esnext"]). بيئة تجربة التمارين تستخدم الـ lib الخاصة بـ ES2022، فيعلن كود البداية هاتين القطعتين بنفسه، وهي معاينة لدمج التصريحات الذي ستتعلّمه في الدرس القادم.

جعل الـ APIs الخاصة بك قابلة للتحرير

تؤتي using أكبر ثمارها حين تُعيد الـ APIs الخاصة بك قيمًا قابلة للتحرير. الـ emitter من باعثات أحداث وبنّاؤون آمنون من حيث النوع أعاد دالة off؛ وإعادة كائن فيه [Symbol.dispose] أيضًا تتيح للمستدعين حصر الاشتراك في كتلة:

TypeScript
function subscribe<K extends keyof CheckoutEvents>(event: K, handler: (p: CheckoutEvents[K]) => void): Disposable {
  const off = bus.on(event, handler);
  return { [Symbol.dispose]: off };
}

async function waitForShipment(orderId: number) {
  using _ = subscribe('order.shipped', (e) => { if (e.orderId === orderId) notify(e); });
  await pollUntilShipped(orderId);
} // listener removed here, however the function ends

المرشّحون الجيدون كل ما له “تراجع” مقابل: الأقفال والحجوزات، واشتراكات الأحداث، والمؤقّتات، والملفات المؤقتة، ومعاملات قواعد البيانات (مع await using). إن وجدت نفسك تكتب try/finally فقط لاستدعاء method تنظيف، فذلك المورد يريد [Symbol.dispose].

الـ decorators القياسية

الـ decorator (المُزخرِف) دالة تُطبَّق بـ @ على صنف أو عضو في صنف. نفّذ TypeScript 5.0 الـ decorators القياسية (TC39)، ويترجمها TypeScript إلى استدعاءات دوال عادية، فتعمل في أي بيئة تشغيل. يتلقّى method decorator الـ method الأصلية وكائن context، ويمكنه إعادة بديل:

TypeScript
function logged<This, Args extends unknown[], Return>(
  method: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log(`→ ${name}`, args);
    return method.call(this, ...args);
  };
}

class PaymentGateway {
  @logged
  charge(amountCents: number, currency: string) { /* … */ }
}

الـ generics تجعل الـ decorator يعمل على أي method دون any: تُستنتج This وArgs وReturn من الـ method التي يزخرفها، ويحتفظ البديل بالتوقيع نفسه. والـ decorators التي تأخذ خيارات هي مصانع decorators (decorator factories)، أي دوال تُعيد الـ decorator:

TypeScript
function retry(times: number) {
  return function <This, Args extends unknown[], Return>(
    method: (this: This, ...args: Args) => Return,
    _context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
  ) {
    return function (this: This, ...args: Args): Return {
      for (let attempt = 1; ; attempt++) {
        try {
          return method.call(this, ...args);
        } catch (e) {
          if (attempt >= times) throw e;
        }
      }
    };
  };
}

class PaymentGateway {
  @retry(3)
  charge(amountCents: number, currency: string) { /* … */ }
}

الـ decorators المكدّسة تُطبَّق من الأسفل إلى الأعلى. مع @logged فوق @retry(3)، يُطبَّق غلاف إعادة المحاولة على charge أولًا ثم يغلّف logged النتيجة، فتحصل على سطر تسجيل واحد لكل استدعاء لا لكل محاولة. بدّلهما فتسجّل كل محاولة. لا أحد منهما خاطئ، لكن الترتيب سلوك، فاختره عن قصد.

ويقدّم context أيضًا context.name وcontext.static وcontext.private وcontext.addInitializer(fn)، التي تنفّذ كودًا حين تُنشأ نسخة (وهي الطريقة المعتادة لكتابة decorator مثل @bound يربط method بنسختها).

في التمرين ستحرّر حجوزات المخزون بـ using وتضيف @retry محدَّد النوع إلى بوابة الدفع في Cartwheel. الدرس القادم: وصف كود لم تكتبه، بملفات التصريحات.

أهم النقاط

  • تصريح using يستدعي [Symbol.dispose]() على القيمة حين تنتهي الكتلة المحيطة، سواء عادت بشكل طبيعي أو رمت خطأً.
  • تصريحات using المتعددة في كتلة واحدة تُحرَّر بترتيب معكوس، مثل كتل try/finally المتداخلة.
  • await using وSymbol.asyncDispose يفعلان الشيء نفسه للتنظيف الذي يُعيد promise.
  • الـ decorators القياسية (منذ TypeScript 5.0) دوال بالشكل (value, context) يمكنها إعادة بديل؛ وهي ليست نفسها experimentalDecorators الأقدم.
  • حدّد نوع method decorator بـ ClassMethodDecoratorContext ومعاملات generic هي This وArgs وReturn كي يعمل على أي method دون any.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

ماذا يطبع هذا؟

TypeScript
function checkout() {
  using a = reserve('TENT-2P');   // logs "reserve TENT-2P", disposes with "release TENT-2P"
  using b = reserve('MUG-SET');   // logs "reserve MUG-SET", disposes with "release MUG-SET"
  throw new Error('card declined');
}
try { checkout(); } catch {}
السؤال 2 من 4

أيّ قيمة يمكن ربطها بـ using؟

السؤال 3 من 4

كيف يتلقّى method decorator القياسي الـ method التي يزخرفها؟

السؤال 4 من 4

تستخدم قاعدة الكود عندك parameter decorators على طريقة NestJS مثل constructor(@Inject(TOKEN) svc). ماذا يعني ذلك لـ tsconfig الخاص بك؟

تدريب

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

حرّر المخزون، وأعِد محاولة الخصم من البطاقة

إصلاحان لنظام الدفع في Cartwheel:

  1. اجعل reserve تُعيد قيمة قابلة للتحرير (فيها method باسم [Symbol.dispose] تطبع release <sku> بدل method باسم release)، وأعِد كتابة placeOrder باستخدام using بحيث يُحرَّر كل حجز حين تنتهي الدالة، بترتيب معكوس، حتى حين يرمي الخصم من البطاقة خطأً.
  2. اكتب مصنع decorator محدَّد الأنواع retry(times) وطبّق @retry(3) على PaymentGateway.charge، بحيث يُحاوَل الخصم من البطاقة الذي يرمي خطأً حتى 3 مرات إجمالًا قبل أن ينتشر الخطأ. أبقِ توقيع charge سليمًا.

أعلى الملف يعلن Symbol.dispose وDisposable، لأن بيئة التجربة تستخدم الـ lib الخاصة بـ ES2022. DisposableStack غير متاحة هنا؛ إن احتجت إليها، فاكتب صنفًا صغيرًا خاصًا بك.

// ---- Playground shim (do not edit) ----
// The playground's lib is ES2022. These lines add what lib.esnext.disposable
// would declare, and make sure Symbol.dispose exists at runtime.
interface SymbolConstructor { readonly dispose: unique symbol; }
interface Disposable { [Symbol.dispose](): void; }
(Symbol as { dispose?: symbol }).dispose ??= Symbol.for('Symbol.dispose');
// ----------------------------------------

const events: string[] = [];

function reserve(sku: string) {
  events.push(`reserve ${sku}`);
  return { sku, release: () => events.push(`release ${sku}`) };
}

class PaymentGateway {
  calls = 0;
  failuresLeft: number;

  constructor(failures: number) {
    this.failuresLeft = failures;
  }

  // Should be retried up to 3 times in total.
  charge(amountCents: number): string {
    this.calls++;
    if (this.failuresLeft > 0) {
      this.failuresLeft--;
      throw new Error('Gateway timeout');
    }
    events.push(`charge ${amountCents}`);
    return `receipt-${amountCents}`;
  }
}

function placeOrder(skus: string[], gateway: PaymentGateway): string {
  const reservations = skus.map((sku) => reserve(sku));
  const receipt = gateway.charge(skus.length * 1000);
  reservations.forEach((r) => r.release());
  return receipt;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<ReturnType<typeof reserve>[typeof Symbol.dispose], () => void>>;
type T2 = Expect<Equal<ReturnType<PaymentGateway['charge']>, string>>;

console.log(placeOrder(['TENT-2P', 'MUG-SET'], new PaymentGateway(0)), events);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها اختبارات الأنواع
  • تُحرَّر الحجوزات بعد الخصم من البطاقة، بترتيب معكوس
  • تُحرَّر الحجوزات حتى حين يفشل الخصم من البطاقة نهائيًا
  • ينجح charge في المحاولة الثالثة بعد إخفاقين
  • يستسلم charge ويعيد رمي الخطأ بعد 3 محاولات