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

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

التحكّم في الـ Inference: NoInfer والقيم الافتراضية والـ Overloads

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

الدرس في لمحة

احجب مواقع الاستنتاج غير المرغوبة بـ NoInfer، وأعطِ معاملات النوع قيمًا افتراضية معقولة، واختر بين الـ overloads والـ unions والدوال المنفصلة حين يعتمد نوع الإرجاع على المدخل.

  1. 1NoInfer: افحص هنا، لكن لا تستنتج من هنا
  2. 2قيم افتراضية لمعاملات النوع
  3. 3حين يعتمد نوع الإرجاع على الوسيط

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

إليك دالة مساعدة من نظام الدفع في Cartwheel. تبدو آمنة، وفيها ثغرة:

TypeScript
function applyCoupon<C extends string>(available: C[], code: C) {
  // …
}

applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10'); // compiles

النية واضحة: يجب أن يكون code واحدًا من الكوبونات المتاحة. لكن C يظهر في المعاملين كليهما، وكما رأيت في الدرس السابق، كل ظهور موقعُ استنتاج. يجمع TypeScript القيم 'WELCOME10' و'LOYAL5' و'WELCOM10' كمرشّحين، ويجعل C هو الـ union من الثلاثة، فيجتاز كل شيء فحص الأنواع. لم يُفحص الخطأ الإملائي مقابل القائمة؛ بل أُضيف إليها.

NoInfer: افحص هنا، لكن لا تستنتج من هنا

أضاف TypeScript 5.4 النوع المدمج NoInfer<T>. يُحَلّ إلى T العادي، لكنه يجعل ذلك الموضع محظورًا على الـ inference:

TypeScript
function applyCoupon<C extends string>(available: C[], code: NoInfer<C>) {
  // …
}

applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10');
// Error: Argument of type '"WELCOM10"' is not assignable to parameter of type '"WELCOME10" | "LOYAL5"'.

الآن يُستنتج C من available وحدها، ويُفحص code مقابل النتيجة. يظهر هذا النمط كلما كان أحد الوسائط يعرّف مجموعة وعلى وسيط آخر أن يختار منها: حالة ابتدائية يجب أن تكون إحدى الحالات المعرَّفة، أو قيمة افتراضية يجب أن تطابق الخيارات، أو مفتاح ترتيب يجب أن يكون أحد الأعمدة المدرَجة.

TypeScript
function createMachine<S extends string>(config: { states: S[]; initial: NoInfer<S> }) {
  return config;
}

createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'cart' });    // ok
createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'checkout' }); // error

قبل 5.4، كان الحل البديل معامل نوع ثانيًا مقيَّدًا بالأول، <C extends string, D extends C>(available: C[], code: D). هذا يعمل، وستراه في المكتبات القديمة، لكنه يضيف معاملًا لا وجود له إلا للتهرّب من الاستنتاج. استخدم NoInfer في الكود الجديد. فهي توثّق النية أيضًا: القارئ الذي يرى initial: NoInfer<S> يعرف من النظرة الأولى أيّ وسيط هو مصدر الحقيقة وأيّها يُفحص فقط، وهذا ما لم توضّحه حيلة المعاملين أبدًا.

قيم افتراضية لمعاملات النوع

يمكن أن تكون لمعامل النوع قيمة افتراضية، تُستخدم حين لا يوجد ما يُستنتج منه:

TypeScript
type ApiResponse<T = unknown> = { data: T; requestId: string };

async function request<TRes = unknown, TBody = undefined>(path: string, body?: TBody): Promise<TRes> {
  const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', body: JSON.stringify(body) });
  return res.json();
}

أمران يجعلان القيم الافتراضية مفيدة. أولًا، ApiResponse وحدها تعني الآن ApiResponse<unknown>، وهي قيمة افتراضية آمنة: على المستدعين تضييق data قبل استخدامها. ثانيًا، القيم الافتراضية تجعل وسائط النوع الصريحة الجزئية مشروعة. دونها كانت request<Order>('/orders/1042') ستفشل مع “Expected 2 type arguments”؛ ومعها يرجع TBody إلى undefined.

اختر قيمًا افتراضية آمنة لا مريحة. unknown قيمة افتراضية جيدة للبيانات القادمة من الخارج؛ أما any فلا، لأنها تُطفئ الفحص بصمت لكل من ينسى وسيط النوع. (ولاحظ أن request<Order> ما زالت تحويلًا متنكّرًا: جسم الاستجابة لا يُفحص. ستُصلح ذلك كما ينبغي في الدرس القادم وفي تحليل البيانات غير الموثوقة عند الحدود.)

حين يعتمد نوع الإرجاع على الوسيط

أحيانًا يحدّد نوعُ الوسيط نوعَ النتيجة. الـ overloads (التحميل الزائد) تصف ذلك مباشرة:

TypeScript
function formatPrice(cents: number): string;
function formatPrice(cents: number[]): string[];
function formatPrice(cents: number | number[]): string | string[] {
  return Array.isArray(cents) ? cents.map(toDollars) : toDollars(cents);
}

function toDollars(cents: number): string {
  return `${(cents / 100).toFixed(2)}`;
}

const one = formatPrice(4500);          // string
const many = formatPrice([4500, 1299]); // string[]

السطران الأولان هما توقيعا الـ overload اللذان يراهما المستدعون. والثالث هو توقيع التنفيذ، الذي يجب أن يتوافق معها جميعًا وهو غير مرئي من الخارج. يجرّب TypeScript الـ overloads من الأعلى إلى الأسفل ويستخدم أول واحد يطابق، فضع الأكثر تحديدًا أولًا.

للـ overloads حافة حادة واحدة: المستدعي الذي بين يديه number | number[] لا يطابق أيًّا من الـ overloads، فيحصل على “No overload matches this call”. ستحتاج إلى overload ثالث يقبل الـ union. وهذه الحافة تلميح بشأن التصميم. إذا كان لدى المستدعين الـ union غالبًا، فالدالة تريد على الأرجح توقيعًا واحدًا؛ وإن كان نادرًا، فدالتان منفصلتان باسمين واضحين (formatPrice وformatPrices) تتفوّقان عادةً على دالة واحدة محمّلة بالـ overloads.

في التمرين ستُصلح ثلاثًا من مشكلات الـ inference هذه في نظام الدفع في Cartwheel. والدرس القادم يجمع كل ما في هذا القسم في عميل API واحد محدَّد الأنواع.

أهم النقاط

  • كل ظهور لمعامل النوع موقعُ استنتاج، فقد يؤدّي خطأ إملائي في وسيط واحد إلى توسيع النوع بدل رفضه.
  • NoInfer<T> (منذ TypeScript 5.4) تجعل الموضع للفحص فقط: يُتحقَّق منه مقابل T لكنه لا يساهم بأي مرشّح.
  • القيم الافتراضية لمعاملات النوع تُطبَّق حين لا يوجد ما يُستنتج منه، وتجعل وسائط النوع الصريحة الجزئية ممكنة.
  • الـ overloads تتيح لنوع الإرجاع أن يعتمد على نوع الوسيط، لكن الوسيط من نوع union لا يطابق أيًّا منها؛ ودالتان بأسماء جيدة كثيرًا ما تكونان أوضح.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

لماذا يمرّ هذا الاستدعاء في الترجمة رغم أن 'WELCOM10' خطأ إملائي؟

TypeScript
function applyCoupon<C extends string>(available: C[], code: C) {}
applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10');
السؤال 2 من 4

function request<TRes = unknown, TBody = undefined>(path: string, body?: TBody): Promise<TRes>. ماذا تفعل request<Order>('/orders/1')؟

السؤال 3 من 4

مع هذه الـ overloads، ماذا يحدث في السطر الأخير؟

TypeScript
function getOrder(id: number): Promise<Order>;
function getOrder(ids: number[]): Promise<Order[]>;
function getOrder(idOrIds: number | number[]): Promise<Order | Order[]> { /* … */ }
declare const input: number | number[];
getOrder(input);
السؤال 4 من 4

تصمّم دالة بحث تُعيد طلبًا واحدًا لمعرّف، وقائمة لعميل. أيّ API هو الأسهل للمستدعين وللمسؤولين عن صيانة الكود؟

تدريب

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

أوقف الأخطاء الإملائية، وحدّد أنواع الـ overloads

أصلح ثلاث مشكلات في الـ inference في نظام الدفع في Cartwheel. لا تغيّر الاستدعاءات تحت اختبارات الأنواع.

  1. يجب أن ترفض applyCoupon(available, code) أي code ليس في available، بدل أن تضيفه إلى الـ union.
  2. يجب أن ترفض createMachine({ states, initial }) أي حالة initial ليست في states.
  3. يجب أن تُعيد formatPrice النوع string مع رقم، وstring[] مع مصفوفة أرقام.
function applyCoupon<C extends string>(available: C[], code: C): string {
  return available.includes(code) ? `Applied ${code}` : `Unknown coupon ${code}`;
}

function createMachine<S extends string>(config: { states: S[]; initial: S }) {
  return { states: config.states, initial: config.initial };
}

function formatPrice(cents: number | number[]): string | string[] {
  return Array.isArray(cents) ? cents.map(toDollars) : toDollars(cents);
}

function toDollars(cents: number): string {
  return `${(cents / 100).toFixed(2)}`;
}

// ---- Type tests (do not edit) ----
applyCoupon(['WELCOME10', 'LOYAL5'], 'LOYAL5');
// @ts-expect-error: 'WELCOM10' is a typo, not an available coupon
applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10');

const machine = createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'cart' });
type T1 = Expect<Equal<typeof machine.initial, 'cart' | 'shipping' | 'payment'>>;
// @ts-expect-error: 'checkout' is not one of the declared states
createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'checkout' });

const one = formatPrice(4500);
const many = formatPrice([4500, 1299]);
type T2 = Expect<Equal<typeof one, string>>;
type T3 = Expect<Equal<typeof many, string[]>>;

console.log(applyCoupon(['WELCOME10'], 'WELCOME10'), machine.initial, one, many);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

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