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

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

باعثات أحداث وبنّاؤون آمنون من حيث النوع

21 دقراءة 3 دبيئة تجريب الكود

الدرس في لمحة

ابنِ event emitter تُفحص أسماء أحداثه وحمولاتها من خريطة أحداث واحدة، وbuilder يكبر نوع نتيجته مع كل استدعاء method.

  1. 1خريطة واحدة، كل الأنواع
  2. 2الـ emitter محدَّد الأنواع
  3. 3بنّاؤون يكبر نوعهم

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

يُطلق نظام الدفع في Cartwheel أحداثًا: order.shipped وpayment.failed وcheckout.opened. وتستمع إليها أدوات التحليلات وخدمة البريد وصفحة الطلب. النسخة الأولى استخدمت emitter عاديًا بأسماء من نوع string وحمولات من نوع any، فأنتجت الخطأين الكلاسيكيين: listener لـ 'order.shiped' لم يُطلَق أبدًا، وhandler يقرأ payload.trackingNo من حدث يرسل trackingNumber. كلاهما كان غير مرئي حتى لاحظ أحدهم رسائل بريد مفقودة.

كل ما في هذا القسم والذي قبله يجتمع لإصلاح ذلك.

خريطة واحدة، كل الأنواع

صِف كل حدث مرة واحدة، في خريطة من الاسم إلى نوع الحمولة:

TypeScript
type CheckoutEvents = {
  'checkout.opened': undefined;
  'order.shipped': { orderId: number; carrier: string; trackingNumber: string };
  'payment.failed': { orderId: number; reason: string };
};

يمكنك أيضًا توليد جزء من هذه الخريطة بالـ mapped type ذي الـ template literal من الـ Template Literal Types. وفي الحالتين، الخريطة هي مصدر الحقيقة الواحد: الأسماء مفاتيحها، والحمولات قيمها.

الـ emitter محدَّد الأنواع

TypeScript
type EventArgs<P> = [P] extends [undefined] ? [] : [payload: P];
type Handler = (payload: never) => void;

class Emitter<Events extends Record<string, unknown>> {
  #handlers = new Map<keyof Events, Set<Handler>>();

  on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): () => void {
    let set = this.#handlers.get(event);
    if (!set) {
      set = new Set();
      this.#handlers.set(event, set);
    }
    set.add(handler);
    return () => set.delete(handler);
  }

  emit<K extends keyof Events>(event: K, ...args: EventArgs<Events[K]>): void {
    for (const handler of this.#handlers.get(event) ?? []) {
      // Handlers for K were registered with a matching payload type in on().
      (handler as (payload: unknown) => void)(args[0]);
    }
  }
}

النمط هو نمط عميل الـ API، مطبَّقًا على الأحداث. يُستنتج K من اسم الحدث وله مهمة واحدة: البحث عن الحمولة في Events[K]. في on، يسري ذلك النوع إلى معامل الـ handler، فيُحدَّد نوع الـ callback من السياق. وفي emit، يفحص الحمولة التي تمرّرها.

TypeScript
const bus = new Emitter<CheckoutEvents>();

const off = bus.on('order.shipped', (e) => sendEmail(`Tracking: ${e.trackingNumber}`));
bus.emit('order.shipped', { orderId: 1042, carrier: 'DHL', trackingNumber: '1Z-999' });
bus.emit('checkout.opened');                         // no payload needed
bus.on('order.shiped', () => {});
// Error: Argument of type '"order.shiped"' is not assignable to parameter of type 'keyof CheckoutEvents'.
bus.emit('payment.failed', { orderId: 1042 });
// Error: … Property 'reason' is missing …
off(); // unsubscribe

EventArgs هي حيلة معامل البقية الشرطي من الـ Conditional Types والتوزيع و infer: الأحداث التي لا حمولة لها لا تأخذ أي وسيط إضافي، والباقية تأخذ واحدًا بالضبط. والأقواس في [P] extends [undefined] توقف التوزيع، فيبقى نوع حمولة مثل string | undefined مطلوبًا بدل أن يُقسم.

لاحظ أين تعيش الأنواع الفضفاضة. داخل الصنف، تُخزَّن الـ handlers كـ Set<Handler> وتُستدعى عبر تحويل واحد مشروح بتعليق، لأن Map لا تستطيع التعبير عن “نوع القيمة يعتمد على المفتاح”. وفي الخارج، كل method دقيقة. هذا الفصل، أي تخزين بلا أنواع خلف واجهة محدَّدة الأنواع، هو طريقة بناء معظم المكتبات جيدة الأنواع. والحقل الخاص #handlers حقل خاص حقيقي في JavaScript، فلا يستطيع شيء من الخارج الوصول إليه وكسر الثابت.

حين يكون القلب محدَّد الأنواع، ترث الـ methods الجديدة الدقة شبه مجانًا. method باسم once تحلّ promise بالحمولة التالية تأخذ أربعة أسطر، ونتيجتها محدَّدة النوع لكل حدث دون تعليقات إضافية:

TypeScript
once<K extends keyof Events>(event: K): Promise<Events[K]> {
  return new Promise((resolve) => {
    const off = this.on(event, (payload) => { off(); resolve(payload); });
  });
}

const shipped = await bus.once('order.shipped'); // { orderId; carrier; trackingNumber }

هذا هو عائد وضع الأنواع في خريطة واحدة وتمرير K عبرها: كل method تضيفها تُفحص مقابل مصدر الحقيقة نفسه، ولا تكرّر أيٌّ منها نوع حمولة.

بنّاؤون يكبر نوعهم

الـ builder (الباني) يسلسل استدعاءات methods لتجميع شيء خطوة بخطوة. والحيلة على مستوى الأنواع هي حمل ما بُني حتى الآن في معامل نوع، وإعادة نوع جديد أوسع من كل استدعاء. إليك builder استعلامات صغيرًا لسجل الطلبات في Cartwheel:

TypeScript
type Order = { id: number; status: OrderStatus; totalCents: number; customer: string };

class OrderQuery<Selected extends keyof Order = never> {
  readonly #fields: readonly (keyof Order)[];

  constructor(fields: readonly (keyof Order)[] = []) {
    this.#fields = fields;
  }

  select<K extends keyof Order>(...fields: K[]): OrderQuery<Selected | K> {
    return new OrderQuery<Selected | K>([...this.#fields, ...fields]);
  }

  run(rows: Order[]): Pick<Order, Selected>[] {
    // #fields holds exactly the keys in Selected.
    return rows.map((row) => Object.fromEntries(this.#fields.map((f) => [f, row[f]])) as Pick<Order, Selected>);
  }
}

const rows = new OrderQuery().select('id').select('totalCents').run(allOrders);
rows[0].totalCents; // number
rows[0].customer;   // Error: Property 'customer' does not exist on type 'Pick<Order, "id" | "totalCents">'.

يبدأ Selected بـ never (لا شيء مختار) وكل select تُعيد OrderQuery<Selected | K>. لا تستطيع الـ method تغيير نوع الكائن الذي تُستدعى عليه، فتُعيد كل خطوة نسخة جديدة. وهذا أيضًا تصميم جيد وقت التشغيل: البنّاؤون الذين لا يعدّلون أبدًا يمكن مشاركتهم وإعادة استخدامهم بأمان. وهي التقنية نفسها التي يستخدمها بنّاؤو الاستعلامات مثل Kysely وDrizzle على نطاق أكبر بكثير.

في التمرين ستبني ناقل الأحداث محدَّد الأنواع في Cartwheel وbuilder اختيار صغيرًا. وبه يكتمل قسم الأنماط؛ والقسم 5 ينتقل إلى إيصال كل هذا إلى الإنتاج، بدءًا بخيارات الـ compiler التي تجعل هذه الأنواع جديرة بالثقة.

أهم النقاط

  • نوع خريطة الأحداث ({ 'order.shipped': { orderId: number } }) هو مصدر الحقيقة الواحد لأسماء الأحداث وأنواع حمولاتها معًا.
  • on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void) تربط معامل الـ handler باسم الحدث الذي يمرّره المستدعي.
  • معامل البقية الشرطي يتيح إطلاق الأحداث التي لا حمولة لها دون وسيط وهمي.
  • يستطيع الـ builder حمل الحالة في معامل نوع، مُعيدًا نوعًا جديدًا أوسع من كل استدعاء، فيعكس نوع النتيجة النهائي كل خطوة.
  • أبقِ التخزين غير محدَّد الأنواع داخل الصنف والأنواع الدقيقة على methods العامة، كي يعيش التحويل الوحيد في مكان واحد.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

مع bus = new Emitter<CheckoutEvents>()، ما نوع p؟

TypeScript
type CheckoutEvents = {
  'order.shipped': { orderId: number; carrier: string };
  'payment.failed': { orderId: number; reason: string };
};
bus.on('payment.failed', (p) => console.log(p.reason));
السؤال 2 من 4

لماذا نحدّد نوع emit بـ ...args: EventArgs<Events[K]> بدل payload: Events[K]؟

السؤال 3 من 4

ما نوع rows؟

TypeScript
const rows = new OrderQuery().select('id').select('totalCents').run(allOrders);
السؤال 4 من 4

لماذا يُعيد كل استدعاء لـ select كائن OrderQuery جديدًا بدل تعديل this وإعادته؟

تدريب

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

حدّد أنواع ناقل الأحداث وبنّاء الاستعلامات

اجعل الأداتين في Cartwheel محدَّدتي الأنواع بالكامل دون تغيير سلوكهما وقت التشغيل:

  1. Emitter<Events>: يجب ألّا تقبل on(event, handler) إلا أسماء أحداث من Events وأن تعطي الـ handler نوع حمولة ذلك الحدث؛ ويجب أن تفحص emit(event, payload) الحمولة، وأن تُطلَق الأحداث التي حمولتها undefined دون وسيط حمولة.
  2. OrderQuery: كل استدعاء لـ select(...fields) يضيف إلى الحقول المختارة، وrun(rows) تُعيد Pick<Order, …>[] من الحقول المختارة بالضبط.

أبقِ أي تحويل داخل الصنفين، في مكان واحد مشروح بتعليق لكلٍّ منهما.

type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus; totalCents: number; customer: string };

const ORDERS: Order[] = [
  { id: 1042, status: 'shipped', totalCents: 18900, customer: 'Mona' },
  { id: 1043, status: 'processing', totalCents: 4500, customer: 'Karim' },
];

class Emitter<Events> {
  #handlers = new Map<string, Set<(payload: any) => void>>();

  on(event: string, handler: (payload: any) => void): () => void {
    let set = this.#handlers.get(event);
    if (!set) {
      set = new Set();
      this.#handlers.set(event, set);
    }
    set.add(handler);
    return () => set.delete(handler);
  }

  emit(event: string, payload?: any): void {
    for (const handler of this.#handlers.get(event) ?? []) handler(payload);
  }
}

class OrderQuery {
  readonly #fields: readonly (keyof Order)[];

  constructor(fields: readonly (keyof Order)[] = []) {
    this.#fields = fields;
  }

  select(...fields: (keyof Order)[]): OrderQuery {
    return new OrderQuery([...this.#fields, ...fields]);
  }

  run(rows: Order[]): Partial<Order>[] {
    return rows.map((row) => Object.fromEntries(this.#fields.map((f) => [f, row[f]])));
  }
}

// ---- Type tests (do not edit) ----
type CheckoutEvents = {
  'checkout.opened': undefined;
  'order.shipped': { orderId: number; carrier: string };
  'payment.failed': { orderId: number; reason: string };
};

function typeTests(bus: Emitter<CheckoutEvents>) {
  bus.on('payment.failed', (e) => {
    type T1 = Expect<Equal<typeof e, { orderId: number; reason: string }>>;
  });
  bus.emit('checkout.opened');
  bus.emit('order.shipped', { orderId: 1042, carrier: 'DHL' });
  // @ts-expect-error: 'order.shiped' is a typo
  bus.on('order.shiped', () => {});
  // @ts-expect-error: payment.failed needs a reason
  bus.emit('payment.failed', { orderId: 1042 });
  // @ts-expect-error: order.shipped needs a payload
  bus.emit('order.shipped');
}

const rows = new OrderQuery().select('id', 'status').run(ORDERS);
type T2 = Expect<Equal<typeof rows, Pick<Order, 'id' | 'status'>[]>>;
const more = new OrderQuery().select('id').select('totalCents').run(ORDERS);
type T3 = Expect<Equal<typeof more, Pick<Order, 'id' | 'totalCents'>[]>>;

console.log(rows, more);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • emit تستدعي فقط الـ handlers الخاصة بذلك الحدث، مع الحمولة
  • الدالة التي تُعيدها on تُزيل الـ handler
  • run تُعيد الحقول المختارة فقط، عبر عدة استدعاءات لـ select