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

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

الـ Conditional Types والتوزيع و infer

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

الدرس في لمحة

اكتب conditional types تختار بين الأنواع، وتوقّع كيف تتوزّع على الـ unions، واستخرج أنواعًا من أنواع أخرى بـ infer، معيدًا بناء Exclude وExtract وReturnType.

  1. 1العامل الثلاثي على مستوى الأنواع
  2. 2التوزيع على الـ unions
  3. 3infer: التقط جزءًا من نوع
  4. 4تصفية المفاتيح حسب نوع القيمة
  5. 5أنواع الإرجاع الشرطية: تعامل معها بحذر

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

في تصميم عميل API بأنواع دقيقة، أجبرت المسارات التي لا مدخل لها المستدعين على كتابة call('GET /me', {}). الإصلاح يحتاج إلى نوع يتّخذ قرارًا: “إن لم يكن في مدخل هذا المسار حقول إلزامية، فاجعل الوسيط اختياريًا؛ وإلا فاطلبه”. الـ mapped types تمرّ على المفاتيح. والـ conditional types (الأنواع الشرطية) تقرّر.

العامل الثلاثي على مستوى الأنواع

TypeScript
type IsArray<T> = T extends readonly unknown[] ? true : false;

type A = IsArray<string[]>; // true
type B = IsArray<Order>;    // false

T extends U ? X : Y تُقرأ مثل العامل الثلاثي: إن كان T قابلًا للإسناد إلى U فالنتيجة X، وإلا Y. وextends هنا هي فحص قابلية الإسناد نفسه الموجود في كل مكان آخر في TypeScript: “هل T مجموعة جزئية من U؟”. وحين يكون T معاملًا generic غير معروف بعد، يؤجّل TypeScript الشرط ويُبقيه كما هو حتى يصل نوع محدّد.

إليك إصلاح عميل الـ API. {} extends Input صادقة تمامًا حين تكون كل خصائص Input اختيارية (فالكائن الفارغ سيحقّقها):

TypeScript
type CallArgs<R extends keyof Routes> = {} extends Routes[R]['input']
  ? [input?: Routes[R]['input']]
  : [input: Routes[R]['input']];

declare function call<R extends keyof Routes>(route: R, ...args: CallArgs<R>): Promise<Routes[R]['output']>;

call('GET /me');                    // ok: input is {}
call('GET /orders');                // ok: every filter is optional
call('GET /orders/:id');            // Error: Expected 2 arguments, but got 1.
call('GET /orders/:id', { id: 1 }); // ok

ينتج الشرط نوع tuple، وينشره معامل البقية (rest parameter) ...args في قائمة المعاملات. وعناصر الـ tuple الموسومة (input?:) تُبقي اسم المعامل في التلميحات. ولم يتغيّر موضع استدعاء واحد.

التوزيع على الـ unions

للـ conditional types سلوك واحد يفاجئ الجميع. حين يكون النوع المفحوص معامل نوع مجرّدًا (naked type parameter) وتمرّر إليه union، يُنفَّذ الشرط مرة لكل عضو وتُجمع النتائج:

TypeScript
type ToArray<T> = T extends unknown ? T[] : never;

type X = ToArray<string | number>; // string[] | number[], not (string | number)[]

يُسمّى هذا التوزيع (distribution)، وهو المحرّك وراء عدد من الـ utility types. إليك Exclude وExtract، كما تعرّفهما الـ lib بالضبط:

TypeScript
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;

type Active = MyExclude<OrderStatus, 'cancelled' | 'returned'>; // 'processing' | 'shipped' | 'delivered'
type Shipped = MyExtract<Order, { status: 'shipped' }>;         // the ShippedOrder variant

كل عضو يمرّ عبر الشرط وحده. والأعضاء المحوَّلة إلى never تختفي، لأن never هو الـ union الفارغ. وMyExtract<Order, { status: 'shipped' }> هي أنظف طريقة لتسمية شكل واحد من discriminated union.

الـ union من processing وshipped وcancelled يدخل Exclude مع cancelled. يُقسم إلى ثلاثة أعضاء. يُختبر كل عضو مقابل cancelled: يبقى processing وshipped، ويصبح cancelled هو never. ثم تُجمع النتائج في processing أو shipped. A | B | C 'processing' ext C? → يبقى 'shipped' ext C? → يبقى 'cancelled' ext C? → never 'processing' | 'shipped'
الشرط التوزيعي يقسم الـ union، ويُنفَّذ لكل عضو، ثم يجمع من جديد

لإيقاف التوزيع، غلّف الطرفين في tuple من عنصر واحد. [T] ليس معامل نوع مجرّدًا، فيُفحص الـ union ككل:

TypeScript
type ToArrayWhole<T> = [T] extends [unknown] ? T[] : never;
type Y = ToArrayWhole<string | number>; // (string | number)[]

infer: التقط جزءًا من نوع

داخل جملة extends، تعلن infer X متغيّر نوع يلتقط ما يقع في ذلك الموضع، إن طابق النمط. وهكذا تعرّف الـ lib النوع ReturnType في جوهره (نسخة الـ lib تقيّد F أيضًا بنوع دالة وترجع إلى any):

TypeScript
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;

type Loaded = MyReturnType<typeof loadOrder>; // Promise<Order>

الحيلة نفسها تستخرج أنواعًا من أي شيء له بنية:

TypeScript
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;

ويمكن أن تحمل infer قيدًا أيضًا، كما في infer S extends string، فلا تطابق إلا حين يكون النوع الملتقَط نصًا، وتعطيك S مضيَّقًا مسبقًا. ستحتاج إلى ذلك في الدرس القادم، حين تلتقط أجزاءً من الـ literal types النصية.

تصفية المفاتيح حسب نوع القيمة

اجمع mapped type وشرطًا ووصولًا بالفهرس، وتستطيع اختيار المفاتيح حسب ماهية قيمها:

TypeScript
type KeysOfType<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];

type Line = { sku: string; quantity: number; unitPriceCents: number };
type NumericField = KeysOfType<Line, number>; // 'quantity' | 'unitPriceCents'

يحوّل الـ mapped type كل خاصية إلى اسم مفتاحها أو إلى never، ثم تجمع [keyof T] القيم في union، تختفي فيه قيم never. يُقرأ بالمقلوب في المرة الأولى؛ اكتبه مرة، وسمِّه جيدًا، وأعِد استخدامه. والآن دالة ترتيب نوعها sortBy(lines, key: KeysOfType<Line, number>) ترفض الترتيب حسب sku.

أنواع الإرجاع الشرطية: تعامل معها بحذر

من المغري إعطاء دالة نوع إرجاع شرطيًا، مثل function format<T extends number | number[]>(x: T): T extends number ? string : string[]. يحصل المستدعون على أنواع دقيقة، لكن التنفيذ لا يحصل عليها: داخل الدالة، يبقى T مجهولًا، ويبقى الشرط مؤجّلًا، ولا يستطيع TypeScript أن يتحقّق من أن return x.toFixed(2) تطابقه. فينتهي بك الأمر إلى كتابة as any أو as T extends number ? string : string[] على كل جملة عودة، أي أن أدقّ كود في الدالة هو الكود الذي لا يفحصه أحد.

لهذا فضّل القسم السابق الـ overloads أو الدوال المنفصلة لهذه المهمة. تتألّق الـ conditional types في الأنواع المشتقّة (CallArgs وExtract وKeysOfType) حيث لا يلزم تنفيذ شيء مقابلها، وتكون في أضعف حالاتها كنوع إرجاع معلَن لمنطق مكتوب يدويًا.

في التمرين ستعيد بناء Exclude وExtract وReturnType وتستخدمها على أنواع الطلبات في Cartwheel. والدرس القادم يطبّق الشروط وinfer على النصوص.

أهم النقاط

  • T extends U ? X : Y تختار X حين يكون T قابلًا للإسناد إلى U، وY في غير ذلك؛ ومع T من نوع generic، ينتظر الاختيار حتى يُعرف T.
  • الشرط على معامل نوع مجرّد يتوزّع على الـ unions، فيُنفَّذ مرة لكل عضو ثم تُجمع النتائج.
  • غلّف الطرفين بأقواس مربّعة، [T] extends [U]، لتختبر الـ union ككل بدل التوزيع.
  • infer تعلن متغيّر نوع داخل جملة extends وتلتقط ما يطابقه، وهكذا يعمل ReturnType وAwaited.
  • تحويل كل مفتاح إلى نفسه أو إلى never ثم الفهرسة بـ [keyof T] يصفّي المفاتيح حسب نوع قيمتها.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

type ToArray<T> = T extends unknown ? T[] : never. ما هو ToArray<string | number>؟

السؤال 2 من 4

أيّ نوع يعطيك الشكل المشحون من الـ union المسمّى Order؟

السؤال 3 من 4

ما هو R؟

TypeScript
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type R = MyReturnType<(id: number) => Promise<Order>>;
السؤال 4 من 4

type IsString<T> = T extends string ? true : false. ما هو IsString<never>؟

تدريب

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

ابنِ Exclude وExtract ورفاقهما

نفّذ هذه الأنواع بالـ conditional types (لا تستخدم Exclude أو Extract أو ReturnType أو Awaited المدمجة):

  • MyExclude<T, U>: أعضاء T غير القابلة للإسناد إلى U.
  • MyExtract<T, U>: أعضاء T القابلة للإسناد إلى U.
  • MyReturnType<F>: نوع إرجاع نوع دالة.
  • UnwrapPromise<T>: القيمة داخل Promise، أو T نفسه إن لم يكن promise.
  • OrderWithStatus<S>: شكل (أو أشكال) Order الذي يكون status فيه هو S.
  • KeysOfType<T, V>: مفاتيح T التي تكون قيمها قابلة للإسناد إلى V.
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order =
  | { id: number; status: 'processing' }
  | { id: number; status: 'shipped'; trackingNumber: string }
  | { id: number; status: 'delivered'; trackingNumber: string; deliveredAt: string }
  | { id: number; status: 'cancelled'; cancelReason: string }
  | { id: number; status: 'returned'; trackingNumber: string; deliveredAt: string; refundCents: number };
type Line = { sku: string; quantity: number; unitPriceCents: number; giftWrapped: boolean };
declare function loadOrder(id: number): Promise<Order>;

type MyExclude<T, U> = T;
type MyExtract<T, U> = T;
type MyReturnType<F> = unknown;
type UnwrapPromise<T> = T;

// The Order variant(s) whose status is S.
type OrderWithStatus<S extends OrderStatus> = Order;

// The keys of T whose values are assignable to V.
type KeysOfType<T, V> = keyof T;

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<MyExclude<OrderStatus, 'cancelled' | 'returned'>, 'processing' | 'shipped' | 'delivered'>>;
type T2 = Expect<Equal<MyExtract<OrderStatus, 'shipped' | 'refunded'>, 'shipped'>>;
type T3 = Expect<Equal<MyReturnType<typeof loadOrder>, Promise<Order>>>;
type T4 = Expect<Equal<MyReturnType<() => void>, void>>;
type T5 = Expect<Equal<UnwrapPromise<Promise<Order>>, Order>>;
type T6 = Expect<Equal<UnwrapPromise<number>, number>>;
type T7 = Expect<Equal<OrderWithStatus<'cancelled'>, { id: number; status: 'cancelled'; cancelReason: string }>>;
type T8 = Expect<Equal<OrderWithStatus<'delivered' | 'returned'>['deliveredAt'], string>>;
type T9 = Expect<Equal<KeysOfType<Line, number>, 'quantity' | 'unitPriceCents'>>;
type T10 = Expect<Equal<KeysOfType<Line, boolean>, 'giftWrapped'>>;

const shippedOnly: OrderWithStatus<'shipped'> = { id: 1042, status: 'shipped', trackingNumber: '1Z-999' };
console.log(shippedOnly.trackingNumber);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ: اختبارات الأنواع العشرة كلها ناجحة