الوحدة 2 · الـ Generics بعمق
تصميم عميل API بأنواع دقيقة
22 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
صمّم عميل fetch تُستمدّ أنواعه من خريطة مسارات واحدة، بحيث يستنتج كل استدعاء أنواع مدخله واستجابته من اسم المسار، ولا يكتب المستدعون أبدًا وسيط نوع أو تحويلًا.
- 1مصدر حقيقة واحد: خريطة المسارات
- 2اسم المسار هو موقع الاستنتاج الوحيد
- 3غير آمن من الداخل، آمن من الخارج
- 4توسيع الخريطة
- 5ما الذي يجعل هذا التصميم جيدًا
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
كل عميل API يبدأ بالطريقة نفسها: fetch(url).then((r) => r.json())، التي تُعيد Promise<any>، فتمتلئ قاعدة الكود ببطء بـ as Order وas Order[] في مواضع الاستدعاء. كل تحويل ادّعاءٌ صغير غير مُراجَع بشأن الخادم. هذا الدرس يجمع القسم كله لبناء عميل لا يكتب فيه المستدعون أي تحويل أو وسيط نوع، ويفشل فيه المسار الخاطئ أو الحقل الناقص أو المعرّف المكتوب بنوع خاطئ في الترجمة.
مصدر حقيقة واحد: خريطة المسارات
ابدأ بكتابة كل endpoint مرة واحدة، كـ type:
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 تعني إضافة سطر واحد هنا.
اسم المسار هو موقع الاستنتاج الوحيد
الآن التوقيع. تريد أن يكتب المستدعي المسار والمدخل، ويحصل على نوع الاستجابة الصحيح:
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، لأنه لا يوجد ما يمكن أن يساهم بمرشّح آخر.
غير آمن من الداخل، آمن من الخارج
داخل call، نوع input هو Routes[R]['input'] لـ R ما مجهول. لا يستطيع 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 مفحوص بالكامل.
- العميل محدَّد الأنواع ما زال يثق باستجابة الخادم؛ تحقّق منها عند الحدود قبل أن تعتمد على نوع المخرج المعلَن.