TypeScript - يمكن تهيئة 'R' بنوع عشوائي قد يكون غير مرتبط بـ 'Response'
حسنًا، أنا أحاول تنفيذ "ناقل الأوامر" بسيط في TypeScript، لكنني أواجه صعوبة مع الأنواع العامة وأتساءل إذا كان بإمكان شخص ما مساعدتي. هذا هو الكود الخاص بي:
هذه هي واجهة الـ commandbus
export default interface CommandBus {
execute: <C extends Command, R extends Response<C>>(command: C) => Promise<R>;
}
هذا هو التنفيذ
export default class AppCommandBus implements CommandBus {
private readonly handlers: Handler<Command, Response<Command>>[];
/* ... constructor ... */
public async execute<C extends Command, R extends Response<C>>(
command: C
): Promise<R> {
const resolvedHandler = this.handlers.find(handler =>
handler.canHandle(command)
);
/* ... check if undef and throw ... */
return resolvedHandler.handle(command);
}
}
وهذا ما يبدو عليه واجهة Handler:
export default interface Handler<C extends Command, R extends Response<C>> {
canHandle: (command: C) => boolean;
handle: (command: C) => Promise<R>;
}
Command هو واجهة فارغة (حالياً)، وResponse يبدو هكذا:
export default interface Response<C extends Command> {
command: C;
}
أحصل على خطأ تجميع التالي في آخر سطر من دالة execute في commandbus وأنا مرتبك تمامًا.
type 'Response<Command>' is not assignable to type 'R'. 'R' could be instantiated with an arbitrary type which could be unrelated to 'Response<Command>'.إذا كان بإمكان أي شخص مساعدتي في فهم ما أفعله خطأ، فسأكون ممتنًا له إلى الأبد!
تعديل
لقد أدركت أنني أستطيع تجاوز هذا باستخدام تحويل النوع:
const resolvedHandler = (this.handlers.find(handler =>
handler.canHandle(command)
) as unknown) as Handler<C, R> | undefined;
لكني ما زلت أرغب في معرفة كيفية حل هذا التحويل المزدوج.
الإجابة رقم 1
الدوال العامة في TypeScript تعمل كدالة تمثل كل المواصفات الممكنة لمعلمات النوع العامة الخاصة بها، حيث أن المستدعي للدالة هو من يحدد معلمة النوع، وليس المنفذ:
type GenericFunction = <T>(x: T) => T;
const cantDoThis: GenericFunction = (x: string) => x.toUpperCase(); // error!
// doesn't work for every T
cantDoThis({a: "oops"}); // caller chooses {a: string}: runtime error
const mustDoThis: GenericFunction = x => x; // okay, verifiably works for every T
mustDoThis({a: "okay"}); // okay, caller chooses {a: string}
لذا، دعونا نلقي نظرة على CommandBus:
interface CommandBus {
execute: <C extends Command, R extends Response<C>>(command: C) => Promise<R>;
}
طريقة execute() في CommandBus هي دالة عامة تدّعي أنها قادرة على قبول command من أي نوع فرعي لـ Command الذي يريده المستدعي (على ما يبدو حسنًا حتى الآن)، وإرجاع قيمة من Promise<R>، حيث أن R هو أي نوع فرعي من Response<C> الذي يريده المستدعي. هذا لا يبدو شيئًا يمكن لأي شخص تنفيذه بشكل معقول، ومن المفترض أنك ستضطر دائمًا إلى التأكد من أن الاستجابة التي تعيدها هي R التي طلبها المستدعي. أشك أن هذا هو قصدك. بدلاً من ذلك، ماذا عن شيء مثل هذا:
interface CommandBus {
execute: <C extends Command>(command: C) => Promise<Response<C>>;
}
هنا، execute() لديها فقط معامل عام واحد، C، الذي يتوافق مع نوع command الممرر. وقيمة الإرجاع هي فقط Promise<Response<C>>، وليس نوع فرعي ما يطلبه المستدعي. هذا يمكن تنفيذه بشكل أكثر منطقية، طالما أن لديك طريقة لضمان وجود معالج مناسب لكل C (مثلًا، عن طريق throw إذا لم يكن موجودًا).
هذا يقودنا إلى واجهة Handler الخاصة بك:
interface Handler<C extends Command, R extends Response<C>> {
canHandle: (command: C) => boolean;
handle: (command: C) => Promise<R>;
}
حتى إذا حررنا أنفسنا من طغيان محاولة تمثيل النوع الفرعي المحدد من Response<C> الذي سينتجه المعالج، مثل هذا:
interface Handler<C extends Command> {
canHandle: (command: C) => boolean;
handle: (command: C) => Promise<Response<C>>;
}
ما زلنا نواجه مشكلة مع canHandle(). والحقيقة هي أن Handler هو نفسه نوع عام. الفرق بين الدوال العامة والأنواع العامة يتعلق بمن يحق له تحديد معامل النوع. بالنسبة للدوال، يكون ذلك للمتصل. بالنسبة للأنواع، يكون ذلك للمُنفذ:
type GenericType<T> = (x: T) => T;
const cantDoThis: GenericType = (x: string) => x.toUpperCase(); // error! no such type
const mustDoThis: GenericType<string> = x => x.toUpperCase(); // okay, T is specified
mustDoThis({ a: "oops" }); // error! doesn't accept `{a: string}`
mustDoThis("okay");
تريد أن يقوم Handler<C> فقط بـ handle() أمر من نوع C، وهذا جيد. لكن طريقة canHandle() الخاصة به تتطلب أيضًا أن يكون الأمر من نوع C، وهذا صارم للغاية. تريد أن يختار المستدعي لـ canHandle() نوع C، وأن تكون قيمة الإرجاع true أو false اعتمادًا على ما إذا كان C الذي اختاره المستدعي يطابق ذلك الذي اختاره المنفذ. لتمثيل هذا في نظام النوع، أقترح جعل canHandle() طريقة تحقق من النوع معرفة من قبل المستخدم على نحو عام لواجهة أساسية ليست عامة على الإطلاق، مثل هذا:
interface SomeHandler {
canHandle: <C extends Command>(command: C) => this is Handler<C>;
}
interface Handler<C extends Command> extends SomeHandler {
handle: (command: C) => Promise<Response<C>>;
}
لذلك، إذا كان لديك SomeHandler، كل ما يمكنك فعله هو استدعاء canHandle(). إذا قمت بتمرير أمر من نوع C له، وعادت canHandle() بالقيمة true، فسيفهم المترجم أن المعالج الخاص بك هو Handler<C> ويمكنك استدعاؤه. هكذا:
function testHandler<C extends Command>(handler: SomeHandler, command: C) {
handler.handle(command); // error! no handle method known yet
if (handler.canHandle(command)) {
handler.handle(command); // okay!
}
}
نحن على وشك الانتهاء. الجزء المعقد الوحيد هو أنك تستخدم طريقة find() الخاصة بـ SomeHandler[] لتحديد واحد مناسب لـ command. المترجم لا يستطيع الاطلاع على رد الاتصال handler => handler.canHandle(command) واستنتاج أن رد الاتصال من النوع (handler: SomeHandler) => handler is SomeHandler<C>، لذلك علينا مساعدته عن طريق توضيح النوع على هذا النحو. عندها سيفهم المترجم أن قيمة الإرجاع من find() هي Handler<C> | undefined:
class AppCommandBus implements CommandBus {
private readonly handlers: SomeHandler[] = [];
public async execute<C extends Command>(
command: C
): Promise<Response<C>> {
const resolvedHandler = this.handlers.find((handler): handler is Handler<C> =>
handler.canHandle(command)
);
if (!resolvedHandler) throw new Error();
return resolvedHandler.handle(command);
}
}
هذا يعمل بشكل جيد مع نظام النوع وهو بأفضل شكل يمكنني جعله. قد يعمل أو لا يعمل لحالة الاستخدام الفعلية الخاصة بك، لكن نأمل أن يعطيك بعض الأفكار حول كيفية استخدام الجنريك بشكل فعال. حظاً سعيداً!
رابط الملعب إلى الكود
