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

الوحدة 3 · البرمجة على مستوى الأنواع

الـ Template Literal Types

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

الدرس في لمحة

ابنِ أنواعًا نصية من أنواع أخرى بالـ template literals، وحوّلها بـ Uppercase وCapitalize، وحلّل أنماط المسارات بـ infer لتحديد أنواع معاملات المسار.

  1. 1بناء النصوص من الأنواع
  2. 2تحويل النصوص
  3. 3تحليل النصوص بـ infer
  4. 4الـ template literals كمفاتيح

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

يُطلق نظام الدفع في Cartwheel أحداثًا مثل order.shipped وpayment.failed، ويبني عميل الـ API فيه الروابط من أنماط مثل /orders/:orderId/lines/:lineId. كلاهما نصوص لها بنية. وتحديد نوعها بـ string يعني أن الخطأ الإملائي في اسم حدث أو معامل المسار الناقص لا يظهر إلا حين لا يُطلَق listener أبدًا أو يُعيد طلبٌ 404. الـ template literal types تتيح للـ compiler أن يرى البنية.

بناء النصوص من الأنواع

الـ template literal type يستخدم صياغة علامات الاقتباس المائلة نفسها في نصوص JavaScript القالبية، لكن مع أنواع في العناصر النائبة:

TypeScript
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'}` هي ستة نصوص. هذه قوة الميزة وخطرها في آنٍ، كما سترى في نهاية الدرس.

الآن يستطيع ناقل الأحداث تحديد أنواع أسماء أحداثه من أنواع المجال التي تملكها أصلًا:

TypeScript
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 وإعادة تسمية المفاتيح. واستخدام شائع آخر هو متغيّرات البيئة وأعلام الميزات:

TypeScript
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 نصي:

TypeScript
type Method<R> = R extends `${infer M} ${string}` ? M : never;

type M1 = Method<'POST /orders/:id/cancel'>; // 'POST'

النمط `${infer M} ${string}` يعني “نصٌّ ما، ثم مسافة، ثم أي شيء”. يطابقه TypeScript مع القيمة الحرفية ويربط M بالجزء الذي قبل أول مسافة.

إليك النوع الذي يجعل باني الروابط آمنًا. إنه يجمع كل :param في نمط المسار:

TypeScript
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>) هو موضوع الدرس القادم. أما الآن فانظر إلى ما يعطيك إياه:

TypeScript
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 الحالات دفعة واحدة:

TypeScript
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 عضو، فأبقِ المدخلات صغيرة.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

كم عضوًا في `${'s' | 'm' | 'l'}-${'red' | 'blue'}`؟

السؤال 2 من 4

ماذا يعطي PathParams<'/orders/:orderId/lines/:lineId'> بالتعريف التعاودي من الدرس؟

السؤال 3 من 4

أيّ قيمة يقبلها type Price = ${number}``؟

السؤال 4 من 4

يبني زميل `${Locale}/${Currency}/${Category}/${Sku}` من أربعة unions فيها 40 و30 و15 و500 عضو. ماذا يحدث؟

تدريب

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

حدّد أنواع أسماء الأحداث وأنماط الروابط

استخدم الـ template literal types لتحديد أنواع نصوص Cartwheel:

  • CheckoutEvent: order.<status> لكل OrderStatus، إضافة إلى payment.<outcome> لكل PaymentOutcome.
  • FlagEnvVar: CHECKOUT_ متبوعة بكل Feature بأحرف كبيرة.
  • PathParams<P>: أسماء كل :param في نمط المسار (never حين لا يوجد أيٌّ منها).

ثم نفّذ buildPath(pattern, params) وقت التشغيل: استبدل كل :param بقيمته من params، مرمّزة للروابط بـ encodeURIComponent.

type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type PaymentOutcome = 'captured' | 'failed';
type Feature = 'express-shipping' | 'gift-wrap';

type CheckoutEvent = string;
type FlagEnvVar = string;
type PathParams<P extends string> = string;

function buildPath<P extends string>(pattern: P, params: Record<PathParams<P>, string | number>): string {
  return pattern;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<CheckoutEvent,
  | 'order.processing' | 'order.shipped' | 'order.delivered' | 'order.cancelled' | 'order.returned'
  | 'payment.captured' | 'payment.failed'>>;
type T2 = Expect<Equal<FlagEnvVar, 'CHECKOUT_EXPRESS-SHIPPING' | 'CHECKOUT_GIFT-WRAP'>>;
type T3 = Expect<Equal<PathParams<'/orders/:orderId/lines/:lineId'>, 'orderId' | 'lineId'>>;
type T4 = Expect<Equal<PathParams<'/customers/:customerId'>, 'customerId'>>;
type T5 = Expect<Equal<PathParams<'/orders'>, never>>;

// @ts-expect-error: 'order.shiped' is a typo
const typo: CheckoutEvent = 'order.shiped';

// @ts-expect-error: lineId is missing
buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042 });

console.log(buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042, lineId: 3 }));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • buildPath تملأ كل معامل وتترك المسارات الخالية من المعاملات كما هي
  • قيم المعاملات مرمّزة للروابط