تایپاسکریپت: افزودن فیلدهای پویا به یک کلاس هنگام اجرا با ایمنی نوع
من در حال تبدیل برخی کدهای جاوا اسکریپت قدیمی به تایپاسکریپت هستم و با یک وضعیت جالب در مورد تایپ برخورد کردهام. یک سیستم ماژول وجود دارد که نمونههای کلاس را در زمان اجرا به ویژگیهای نامگذاری شده یک نمونه از کلاس دیگر بارگذاری میکند، و من در حال تلاش برای پیدا کردن نحوه تایپ کردن این شیء هستم:
class Module {
public name: string;
}
class ThisModule extends Module {
public name = 'thisModule';
public log = () => {
console.log('ThisModule called!');
};
}
class ThatModule extends Module {
public name = 'thatModule';
public log = () => {
console.log('ThatModule called!');
};
}
interface IModule extends Module {}
type ModulesInput = Module[];
class Container {
public init = (modules: ModulesInput) => {
for (const module of modules) {
this[module.name] = module;
}
};
}
const thisModule = new ThisModule();
const thatModule = new ThatModule();
const container = new Container<ThisModule | ThatModule >();
const modules: ModulesInput = [
thisModule,
thatModule,
];
container.init(modules);
// Should not throw TS error
container.thisModule.log();
container.thatModule.log();
// Should throw error ("Property anythingElse does not exist on Container")
container.anythingElse.log();
مشکل این است که TypeScript شناسایی نمیکند که container.thisModule یا container.thatModule باید وجود داشته باشند. پیام خطا نشان میدهد TS2339: Property 'thisModule' does not exist on type 'Container'. (و همینطور برای thatModule نیز به همین صورت).
آیا راهی وجود دارد که بتوان این سیستم را تایپ کرد؟ تاکنون کمی موفقیت محدود داشتم در افزودن چند نوع جنریک به کلاس Container (مثلاً type keys='thisModule' | 'thatModule' و type ModulesInUse=ThisModule | ThatModule)، اما آیا TypeScript میتواند نامها را از روی کلاسها کشف کند و بهطور پویا یاد بگیرد که باید انتظار داشته باشد آن کلیدها در شیء container نوع کلاسهای مربوطه خود را داشته باشند؟
پیشاپیش ممنون!
پاسخ شماره ۱
برای رفع خطا از این خط this[module.name]=module; شما نیاز دارید که امضای نمایه را ارائه دهید: [prop: string]: Module.
لطفاً توجه داشته باشید که تایپاسکریپت تغییرات را پیگیری نمیکند به جز در یک مورد، بنابراین شما نیاز دارید که this جدید را از خصوصیت init بازگردانید.
کل کد:
class Module {
public name: string = '' // need to be initialized
}
class ThisModule extends Module {
public name = 'thisModule' as const;
public log = () => {
console.log('ThisModule called!');
};
}
class ThatModule extends Module {
public name = 'thatModule' as const;
public log = () => {
console.log('ThatModule called!');
};
}
type Reduce<
T extends ReadonlyArray<Module>,
Acc extends Record<string, unknown> = {}
> =
(T extends []
? Acc
: (T extends [infer Head, ...infer Tail]
? (Tail extends Module[]
? (Head extends Module
? Reduce<Tail, Acc & Record<Head['name'], Head>>
: never)
: never)
: never)
)
class Container {
[prop: string]: Module; // you need to provide index signature to class in order to use this[module.name] = module
public init = <M extends Module, Modules extends M[]>(modules: [...Modules]) => {
for (const module of modules) {
this[module.name] = module;
}
return this as Reduce<[...Modules]>
};
}
const thisModule = new ThisModule();
const thatModule = new ThatModule()
const container = new Container();
const newContainer = container.init([
thisModule,
thatModule,
]);
// Should not throw TS error
newContainer.thisModule.log()
newContainer.thatModule.log();
// Should throw error ("Property anythingElse does not exist on Container")
newContainer.anythingElse.log() // error
زمین بازی
من از نوع کمکی Reduce استفاده کردهام، که تقریباً مانند Array.prototype.reduce عمل میکند، تمام عناصر را از تاپل به یک شیء تبدیل میکند.
همچنین، من از انواع تاپل متغیر (variadic-tuple-types) برای استنتاج هر ماژول از آرگومان init <M extends Module, Modules extends M[]>(modules: [...Modules]) استفاده کردهام.
در اینجا همچنین نسخهای وجود دارد که از asserts this is ... برای انجام اعتبارسنجی در محل بهجای بازگرداندن مقدار جدید استفاده میکند.
پاسخ شماره ۲
تایپسکریپت محدود به این فرض است که یک کلاس نمیتواند «فیلدهای دینامیک» داشته باشد، زیرا بررسی نوع آنها در داخل کلاس واقعاً سخت است، با این حال اجتماع فیلدهای درست و کلاس معتبر است و نوشتن قراردادهای نوع صحیح در توابع کاملاً ممکن است. دو راه برای به دست آوردن اطلاعات مربوطه وجود دارد:
گزینه ۱: داشتن ویژگی name با نوع دادهی literal
اگر ویژگی name ماژولهای شما به جای اینکه به صورت string تایپ شده باشد، به عنوان رشتهی متنی که در آن قرار دارد تایپ شده باشد، فضای بیشتری برای استفادهی بعدی از آن وجود دارد،
abstract class Module {
public abstract name: string;
}
class ThisModule extends Module {
public name:'thisModule' = 'thisModule';
// ^^^^^ this is the important bit
public logThis = () => { console.log('ThisModule called!'); };
}
class ThatModule extends Module {
public name:'thatModule' = 'thatModule';
// ^^^^^ this is the important bit
public logThat = () => { console.log('ThatModule called!'); };
}
با این میتوانید نوع کانتینری که به دنبال آن هستید را با چیزی شبیه به این تعریف کنید:
type TypesafeContainer<ModuleUnion extends Module> = {
[K in ModuleUnion["name"]]: ModuleUnion["name"] extends K ? ModuleUnion : never;
}
این یک ژنریک میگیرد که ترکیبی از چندین ماژول است و نوعی که به دنبال آن هستید را تولید میکند:
declare function makeContainer<T extends Module>(modules: T[]): TypesafeContainer<T>
const thisModule = new ThisModule();
const thatModule = new ThatModule();
const container = makeContainer([thisModule, thatModule]);
// does not throw TS error
container.thisModule.logThis();
container.thatModule.logThat();
// Should throw error ("Property anythingElse does not exist on Container")
container.anythingElse.log();
یکی از مشکلات این است که اگر سعی کنید یک کلاس Container را اعلام کنید تا این نوع را گسترش دهد، خطایی دریافت میکنید که میگوید «یک رابط تنها میتواند یک نوع شیء یا اشتراک انواع شیء با اعضای شناختهشده بهصورت ایستا را گسترش دهد.»
class Container<T extends Module> {
public constructor(modules: T[]) {
for (const [module] of modules) {
this[module.name] = module;
}
};
}
interface Container<T extends Module> extends TypesafeContainer<T>{}
//^^ this is not allowed :(
من فکر میکنم این عمدتاً یک محدودیت TypeScript است، با این ساختار از نظر تئوری هیچ مشکلی وجود ندارد.
گزینه ۲: از یک رابط کاربری تمام ماژولهای مجاز انتخاب کنید
این امکان وجود دارد که حمل یک جنریک با هر ماژول ممکن است آزاردهنده باشد و اگر جنریک برای نام را در هر جایی رها کنید، ممکن است با مشکلات تایپینگ مبهمتری مواجه شوید، یک جایگزین این است که به طور صریح یک اینترفیس با تمام ماژولهای پشتیبانی شده لیست کنید:
interface CONTAINER_ALL_MODULES {
thisModule: ThisModule
thatModule: ThatModule
}
// still not valid unfortunately :(
interface Container2<K extends keyof CONTAINER_ALL_MODULES> extends Pick<CONTAINER_ALL_MODULES, K>{
new(modules: Array<CONTAINER_ALL_MODULES[K]>): this
}
این رابط برای کانتینر با همان محدودیت مواجه میشود که نمیتوانید یک نوع را با اعضای پویا گسترش دهید، اما هنوز یک تابع با امضای نوع صحیح امکانپذیر است:
class RealContainer {
// actual stuff here
}
declare function makeContainer2<K extends keyof CONTAINER_ALL_MODULES>(modules: Array<CONTAINER_ALL_MODULES[K]>): RealContainer & Pick<CONTAINER_ALL_MODULES, K>
const container2 = makeContainer2([thisModule, thatModule]);
// does not throw TS error
container2.thisModule.logThis();
container2.thatModule.logThat();
// Should throw error ("Property anythingElse does not exist on Container")
container2.anythingElse.log();
امیدوارم این مفید باشد، امیدوارم چیزی پیدا کنید که برای شما کار کند :)
محیط آزمایشی تایپاسکریپت
