الوحدة 3 · البرمجة على مستوى الأنواع
الـ Template Literal Types
17 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
ابنِ أنواعًا نصية من أنواع أخرى بالـ template literals، وحوّلها بـ Uppercase وCapitalize، وحلّل أنماط المسارات بـ infer لتحديد أنواع معاملات المسار.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
يُطلق نظام الدفع في Cartwheel أحداثًا مثل order.shipped وpayment.failed، ويبني عميل الـ API فيه الروابط من أنماط مثل /orders/:orderId/lines/:lineId. كلاهما نصوص لها بنية. وتحديد نوعها بـ string يعني أن الخطأ الإملائي في اسم حدث أو معامل المسار الناقص لا يظهر إلا حين لا يُطلَق listener أبدًا أو يُعيد طلبٌ 404. الـ template literal types تتيح للـ compiler أن يرى البنية.
بناء النصوص من الأنواع
الـ template literal type يستخدم صياغة علامات الاقتباس المائلة نفسها في نصوص JavaScript القالبية، لكن مع أنواع في العناصر النائبة:
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type OrderEvent = `order.${OrderStatus}`;
// 'order.processing' | 'order.shipped' | 'order.delivered' | 'order.cancelled' | 'order.returned'
type CheckoutEvent = OrderEvent | `payment.${'captured' | 'failed'}`;حين يحمل عنصر نائب union، يكون للنتيجة عضو واحد لكل عضو في الـ union. ومع عنصرين نائبين تحصل على كل التركيبات: `${'s' | 'm' | 'l'}-${'red' | 'blue'}` هي ستة نصوص. هذه قوة الميزة وخطرها في آنٍ، كما سترى في نهاية الدرس.
الآن يستطيع ناقل الأحداث تحديد أنواع أسماء أحداثه من أنواع المجال التي تملكها أصلًا:
declare function on(event: CheckoutEvent, handler: () => void): void;
on('order.shipped', () => {});
on('order.shiped', () => {});
// Error: Argument of type '"order.shiped"' is not assignable to parameter of type 'CheckoutEvent'.أضف حالة إلى OrderStatus فيصبح order.on-hold اسم حدث صالحًا تلقائيًا.
تحويل النصوص
يأتي TypeScript بأربعة أنواع نصية مدمجة (intrinsic) تحوّل الأنواع الحرفية: Uppercase وLowercase وCapitalize وUncapitalize. استخدمت Capitalize لأسماء الـ setters في الـ Mapped Types وإعادة تسمية المفاتيح. واستخدام شائع آخر هو متغيّرات البيئة وأعلام الميزات:
type Feature = 'express-shipping' | 'gift-wrap';
type FlagEnvVar = `CHECKOUT_${Uppercase<Feature>}`;
// 'CHECKOUT_EXPRESS-SHIPPING' | 'CHECKOUT_GIFT-WRAP'إنها مبنية داخل الـ compiler لا معرَّفة بلغة TypeScript، فلا يمكنك رؤية مصدرها، لكنها تتصرّف مثل أي نوع generic آخر.
تحليل النصوص بـ infer
تعمل الـ template literal types أيضًا كـ أنماط في conditional type. ومع infer، تستخرج أجزاءً من literal type نصي:
type Method<R> = R extends `${infer M} ${string}` ? M : never;
type M1 = Method<'POST /orders/:id/cancel'>; // 'POST'النمط `${infer M} ${string}` يعني “نصٌّ ما، ثم مسافة، ثم أي شيء”. يطابقه TypeScript مع القيمة الحرفية ويربط M بالجزء الذي قبل أول مسافة.
إليك النوع الذي يجعل باني الروابط آمنًا. إنه يجمع كل :param في نمط المسار:
type PathParams<P extends string> =
P extends `${string}:${infer Param}/${infer Rest}`
? Param | PathParams<Rest>
: P extends `${string}:${infer Param}`
? Param
: never;
type P1 = PathParams<'/orders/:orderId/lines/:lineId'>; // 'orderId' | 'lineId'
type P2 = PathParams<'/orders'>; // neverاقرأه كحالتين. إن كان هناك معامل يليه مزيد من المسار، التقط المعامل وعاوِد على الباقي. وإن كان هناك معامل في النهاية، التقطه. وإلا فلا معاملات. حين يطابق TypeScript نمطًا، يأخذ كل عنصر نائب infer أقصر نص يسمح لبقية النمط بالمطابقة، فيتوقّف Param عند أول /.
التعاود (PathParams<Rest>) هو موضوع الدرس القادم. أما الآن فانظر إلى ما يعطيك إياه:
function buildPath<P extends string>(pattern: P, params: Record<PathParams<P>, string | number>): string {
// Every :param in the pattern has a key in params, by construction.
const values = params as Record<string, string | number>;
return pattern.replace(/:(\w+)/g, (_, name: string) => encodeURIComponent(String(values[name])));
}
buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042, lineId: 3 }); // '/orders/1042/lines/3'
buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042 });
// Error: … Property 'lineId' is missing in type '{ orderId: number; }' …يُستنتج P على أنه النمط الحرفي، ويحوّله PathParams<P> إلى المفاتيح المطلوبة، ويحوّل Record تلك المفاتيح إلى شكل params. والمسار الذي لا معاملات فيه يأخذ {}.
الـ template literals كمفاتيح
أسماء الأحداث نصف القصة فقط؛ كل حدث يحمل أيضًا حمولة. يستطيع mapped type مع جملة as بناء جدول الاسم إلى الحمولة كاملًا من union الحالات دفعة واحدة:
type OrderEventPayloads = {
[S in OrderStatus as `order.${S}`]: { orderId: number; status: S };
};
type ShippedPayload = OrderEventPayloads['order.shipped'];
// { orderId: number; status: 'shipped' }تمرّ الحلقة على الحالات، وتحوّل جملة as كل واحدة منها إلى اسم حدث، ويظل نوع الحمولة قادرًا على الإشارة إلى S الأصلي. هذا الجدول هو بالضبط ما يحتاجه event emitter محدَّد الأنواع، وستبني واحدًا فوقه في باعثات أحداث وبنّاؤون آمنون من حيث النوع. لاحظ أنه لا شيء جديد هنا: mapped type وtemplate literal ووصول بالفهرس، مجتمعة. معظم كود مستوى الأنواع الحقيقي قطع صغيرة كهذه، مركّبة بعناية.
في التمرين ستحدّد أنواع أسماء الأحداث وباني الروابط في Cartwheel. والدرس القادم ينظر في التعاود داخل الأنواع: ما فائدته، وإلى أي عمق يمكن أن يصل، ومتى يكون النوع الأبسط هو الخيار الهندسي الأفضل.
أهم النقاط
- الـ template literal type مثل
`order.${OrderStatus}`يبني literal types نصية، والـ union داخله ينتج كل التركيبات. UppercaseوLowercaseوCapitalizeوUncapitalizeأنواع مدمجة تحوّل الـ literal types النصية.inferداخل نمط template literal تلتقط أجزاءً من النص، ما يتيح لك تحليل أنماط مسارات مثل/orders/:orderIdعلى مستوى الأنواع.- العناصر النائبة
${number}و${string}تقبل أنماطًا فضفاضة؛ توثّق صيغة لكنها لا تتحقّق منها بصرامة. - الـ unions داخل الـ template literals تتضاعف، ويرفض TypeScript الـ unions التي تتجاوز 100,000 عضو، فأبقِ المدخلات صغيرة.