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

الوحدة 2 · الـ Generics بعمق

تصميم عميل API بأنواع دقيقة

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

الدرس في لمحة

صمّم عميل fetch تُستمدّ أنواعه من خريطة مسارات واحدة، بحيث يستنتج كل استدعاء أنواع مدخله واستجابته من اسم المسار، ولا يكتب المستدعون أبدًا وسيط نوع أو تحويلًا.

  1. 1مصدر حقيقة واحد: خريطة المسارات
  2. 2اسم المسار هو موقع الاستنتاج الوحيد
  3. 3غير آمن من الداخل، آمن من الخارج
  4. 4توسيع الخريطة
  5. 5ما الذي يجعل هذا التصميم جيدًا

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

كل عميل API يبدأ بالطريقة نفسها: fetch(url).then((r) => r.json())، التي تُعيد Promise<any>، فتمتلئ قاعدة الكود ببطء بـ as Order وas Order[] في مواضع الاستدعاء. كل تحويل ادّعاءٌ صغير غير مُراجَع بشأن الخادم. هذا الدرس يجمع القسم كله لبناء عميل لا يكتب فيه المستدعون أي تحويل أو وسيط نوع، ويفشل فيه المسار الخاطئ أو الحقل الناقص أو المعرّف المكتوب بنوع خاطئ في الترجمة.

مصدر حقيقة واحد: خريطة المسارات

ابدأ بكتابة كل endpoint مرة واحدة، كـ type:

TypeScript
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus; totalCents: number };
type CancelledOrder = { id: number; status: 'cancelled'; cancelReason: string };
type Page<T> = { items: T[]; nextCursor: string | null };

type Routes = {
  'GET /orders/:id': { input: { id: number }; output: Order };
  'GET /orders': { input: { status?: OrderStatus; cursor?: string }; output: Page<Order> };
  'POST /orders/:id/cancel': { input: { id: number; reason: string }; output: CancelledOrder };
};

كل مفتاح يسمّي endpoint، الطريقة والمسار معًا. وكل قيمة تقول ما يأخذه الـ endpoint وما يُعيده. لن يذكر أي شيء آخر في العميل Order أو Page مباشرة؛ كل ذلك سيُشتقّ من هذا الجدول. وإضافة endpoint تعني إضافة سطر واحد هنا.

اسم المسار هو موقع الاستنتاج الوحيد

الآن التوقيع. تريد أن يكتب المستدعي المسار والمدخل، ويحصل على نوع الاستجابة الصحيح:

TypeScript
async function call<R extends keyof Routes>(
  route: R,
  input: Routes[R]['input'],
): Promise<Routes[R]['output']> {
  // implementation below
}

const order = await call('GET /orders/:id', { id: 1042 });        // Order
const page = await call('GET /orders', { status: 'shipped' });    // Page<Order>
await call('POST /orders/:id/cancel', { id: 1042 });
// Error: Argument of type '{ id: number; }' is not assignable to parameter
// of type '{ id: number; reason: string; }'.
//   Property 'reason' is missing in type '{ id: number; }' but required in type …

كل فكرة من هذا القسم تعمل هنا. R يظهر ثلاث مرات، رابطًا المسار بالمدخل والمخرج. قيده، keyof Routes، هو union من قيم نصية حرفية، فيُستنتج الوسيط على أنه القيمة الحرفية 'GET /orders/:id' لا string. ولـ R موقع استنتاج واحد فقط، هو route؛ أما input فـيستخدم R فقط، عبر الوصول بالفهرس Routes[R]['input']. يستنتج TypeScript المسار، ثم يفحص المدخل مقابل ما يحتاجه ذلك المسار. لا حاجة إلى NoInfer، لأنه لا يوجد ما يمكن أن يساهم بمرشّح آخر.

يمرّر موضع الاستدعاء نص مسار وكائن مدخل. يُستنتج نص المسار على أنه المفتاح الحرفي R. يفهرس R الخريطة Routes، فتنتج نوع المدخل الذي يُفحص به وسيط المدخل، ونوع المخرج المستخدَم للـ promise المُعاد. 'GET /orders/:id' { id: 1042 } R Routes[R] ['input'] يفحص ['output'] Promise<Order>
يُستنتج مفتاح المسار مرة واحدة؛ ويُبحث عن المدخل والمخرج انطلاقًا منه

غير آمن من الداخل، آمن من الخارج

داخل call، نوع input هو Routes[R]['input'] لـ R ما مجهول. لا يستطيع TypeScript معرفة الحقول التي فيه، فبناء الرابط يحتاج إلى تحويل. وهذا مقبول، ما دام يحدث في مكان واحد بالضبط:

TypeScript
type Transport = (method: string, path: string, body: unknown) => Promise<unknown>;

function createClient(transport: Transport) {
  return async function call<R extends keyof Routes>(
    route: R,
    input: Routes[R]['input'],
  ): Promise<Routes[R]['output']> {
    const [method, template] = route.split(' ');
    // The route map guarantees every :param has a matching input field.
    const values = input as Record<string, unknown>;
    const rest: Record<string, unknown> = { ...values };
    const path = template.replace(/:(\w+)/g, (_, name: string) => {
      delete rest[name];
      return encodeURIComponent(String(values[name]));
    });
    const result = await transport(method, path, method === 'GET' ? undefined : rest);
    return result as Routes[R]['output'];
  };
}

هناك تحويلان، كلاهما مشروح بتعليق، وكلاهما في الدالة الوحيدة التي يمرّ بها كل طلب. يحصل المستدعون على API مفحوص بالكامل، والمراجع الذي يريد تدقيق الأجزاء غير الآمنة يقرأ عشرة أسطر. قارن ذلك بقاعدة كود يقول فيها كل موضع من مئتي موضع استدعاء as Order.

حقن الـ transport قرار تصميمي يستحق التقليد. التطبيق الحقيقي يمرّر دالة تستدعي fetch؛ والاختبارات تمرّر نسخة مزيّفة تسجّل الطلبات وتُعيد بيانات جاهزة. والأنواع لا تهتم بأيّهما.

توسيع الخريطة

للعملاء الحقيقيين endpoints لا تأخذ شيئًا، مثل 'GET /me'. مع هذا التصميم تعطيها input: {} ويكتب المستدعون call('GET /me', {}). ذلك الكائن الفارغ عيبٌ صغير، وهو مقايضة عادلة حاليًا: البديل توقيع يجعل input اختياريًا لبعض المسارات فقط، وهذا يحتاج إلى conditional type. ستملك تلك الأداة بعد الـ Conditional Types والتوزيع و infer، ولن يمسّ التغيير إلا التوقيع، لا موضع استدعاء واحدًا.

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

ما الذي يجعل هذا التصميم جيدًا

انظر إلى ما لا يضطر المستدعي إلى فعله أبدًا: كتابة <Order>، أو التحويل، أو استيراد أنواع الاستجابات، أو تذكّر أيّ endpoints تحتاج أيّ حقول. أفضل الـ APIs الـ generic تبدو كأنها بلا أنواع إطلاقًا، لأن كل نوع فيها مستنتج من شيء كان على المستدعي كتابته على أي حال (هنا، اسم المسار).

في التمرين ستبني هذا العميل مقابل transport مزيّف. وبه ينتهي قسم الـ generics. القسم 3 يفكّك عوامل الأنواع (type operators) التي كنت تستعيرها (keyof والوصول بالفهرس) ويضيف الـ mapped والـ conditional والـ template literal types، كي تشتقّ المزيد من مصدر حقيقة واحد.

أهم النقاط

  • ضع مدخل كل endpoint ومخرجه في نوع واحد هو خريطة المسارات، واشتقّ توقيعات العميل منه.
  • اجعل اسم المسار موقع الاستنتاج الوحيد؛ وكل ما عداه يُبحث عنه انطلاقًا منه بالوصول بالفهرس.
  • أبقِ التحويلات التي لا مفرّ منها داخل التنفيذ، في مكان واحد، كي يحصل المستدعون على API مفحوص بالكامل.
  • العميل محدَّد الأنواع ما زال يثق باستجابة الخادم؛ تحقّق منها عند الحدود قبل أن تعتمد على نوع المخرج المعلَن.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

بالنظر إلى call<R extends keyof Routes>(route: R, input: Routes[R]['input']): Promise<Routes[R]['output']>، كيف يُحدَّد R في call('GET /orders/:id', { id: 1042 })؟

السؤال 2 من 4

يحتاج تنفيذ call إلى قراءة معاملات المسار من input، ونوعه Routes[R]['input']. ما النهج الأكثر معقولية؟

السؤال 3 من 4

يعلن عميلك أن 'GET /orders/:id' يُعيد Order. يطلق الخادم تغييرًا يعيد تسمية status إلى state. ماذا يفعل TypeScript؟

السؤال 4 من 4

لماذا يُفضَّل تحديد المسار كنص واحد مثل 'POST /orders/:id/cancel' بدل تمرير الطريقة والمسار كوسيطين منفصلين؟

تدريب

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

حدّد أنواع عميل الدفع من خريطة مسارات

ابنِ الدالة call محدَّدة الأنواع في Cartwheel.

  1. أضف المسار 'POST /orders/:id/cancel' إلى Routes: مدخله { id: number; reason: string } ومخرجه CancelledOrder.
  2. اجعل call generic على اسم المسار، بحيث يُبحث عن نوع input ونوع القيمة المحلولة في Routes. اختبارات الأنواع تُظهر ما يتوقّعه المستدعون: بلا وسائط نوع، وبلا تحويلات.
  3. وقت التشغيل، استبدل كل :param في المسار بقيمة المدخل المطابقة، وأرسل الحقول المتبقّية كجسم للطلبات غير GET. يجب أن ترسل call('POST /orders/:id/cancel', { id: 7, reason: 'Late' }) الطلب POST /orders/7/cancel بالجسم {"reason":"Late"}.
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus; totalCents: number };
type CancelledOrder = { id: number; status: 'cancelled'; cancelReason: string };
type Page<T> = { items: T[]; nextCursor: string | null };

// A fake server: records every request and returns canned data.
const sent: string[] = [];
async function transport(method: string, path: string, body: unknown): Promise<unknown> {
  sent.push(`${method} ${path}${body === undefined ? '' : ' ' + JSON.stringify(body)}`);
  if (path.endsWith('/cancel')) return { id: 1042, status: 'cancelled', cancelReason: 'Changed mind' };
  if (path === '/orders') return { items: [], nextCursor: null };
  return { id: 1042, status: 'processing', totalCents: 18900 };
}

type Routes = {
  'GET /orders/:id': { input: { id: number }; output: Order };
  'GET /orders': { input: { status?: OrderStatus; cursor?: string }; output: Page<Order> };
  // Add: POST /orders/:id/cancel takes { id, reason } and returns a CancelledOrder
};

async function call(route: string, input: Record<string, unknown>): Promise<unknown> {
  const [method, path] = route.split(' ');
  // Path parameters like :id are not filled in yet.
  return transport(method, path, method === 'GET' ? undefined : input);
}

// ---- Type tests (do not edit) ----
async function typeTests() {
  const order = await call('GET /orders/:id', { id: 1042 });
  type T1 = Expect<Equal<typeof order, Order>>;

  const page = await call('GET /orders', { status: 'shipped' });
  type T2 = Expect<Equal<typeof page, Page<Order>>>;

  const cancelled = await call('POST /orders/:id/cancel', { id: 1042, reason: 'Changed mind' });
  type T3 = Expect<Equal<typeof cancelled, CancelledOrder>>;

  // @ts-expect-error: ids are numbers
  await call('GET /orders/:id', { id: '1042' });
  // @ts-expect-error: cancelling needs a reason
  await call('POST /orders/:id/cancel', { id: 1042 });
  // @ts-expect-error: there is no such route
  await call('DELETE /orders/:id', { id: 1042 });
}
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • تُملأ معاملات المسار: GET /orders/1042
  • مسار الإلغاء يرسل إلى /orders/7/cancel والجسم فيه الحقول المتبقّية فقط