javascript - TypScript Klassgenerics och Polymorfa attribut
Jag har problem med generiska och polymorfa attribut. Håller jag på att göra något galet?
Jag har reducerat den här koden ganska mycket. Jag har två (eller fler) klasser (WelcomeTemplate & AddToAccountTemplate) som utökar en basklass (TemplateContext). Var och en implementerar sin egen form av context. Målet är att när EmailService initialiseras, kommer den att ta namnet på mallen och TypeScript kommer att vara medveten om vilken context som därför behövs givet mallen.
Till exempel:
new EmailService("WelcomeTemplate", {
signupUrl: 'https://test.com',
});
Men inom EmailService när jag initierar mallen får jag följande två TypeScript-fel som får mig att klia mig i huvudet. Jag har experimenterat med flera tillvägagångssätt, men hamnar alltid tillbaka vid dessa fel.
Argument of type 'AddToAccountContext | WelcomeContext' is not assignable to parameter of type 'AddToAccountContext & WelcomeContext'.och
AddToAccountTemplate' is assignable to the constraint of type 'Template', but 'Template' could be instantiated with a different subtype of constraint 'AddToAccountTemplate | WelcomeTemplate'.Naturligtvis AddToAccountContext | WelcomeContext och AddToAccountContext & WelcomeContext kolliderar. Men jag är inte säker på varför TypeScript drar slutsatsen & eftersom jag gör Templates[Key] som från generics vilket jag tror borde ha typerna för endast Key och inte en sammanfogad typ för alla Keys.
Finns det bara ett bättre sätt att organisera underklasser för detta scenario?
Här är en TypeScript Playground. Det kan vara enklare att experimentera där.
Tack för att du tittade med mig!
interface ITemplate<Context> {
readonly context?: Context;
}
type TemplateContext = {};
class Template implements ITemplate<TemplateContext> {
public context = {};
}
type AddToAccountContext = {
loginUrl: string;
accountName: string;
};
class AddToAccountTemplate extends Template {
constructor(context: AddToAccountContext) {
super();
this.context = context;
}
public context: AddToAccountContext;
}
type WelcomeContext = {
signupUrl: string;
};
class WelcomeTemplate extends Template {
constructor(context: WelcomeContext) {
super();
this.context = context;
}
public context: WelcomeContext;
}
const templates = Object.freeze({
WelcomeTemplate,
AddToAccountTemplate,
});
type Templates = typeof templates;
class EmailService<
Key extends keyof Templates,
Template extends InstanceType<Templates[Key]>,
Context extends Template["context"],
> {
templateName: Key;
context: Context;
template: InstanceType<Templates[Key]>;
constructor(templateName: Key, context: Context) {
this.templateName = templateName;
this.context = context;
const TemplateClass = templates[this.templateName];
this.template = new TemplateClass(this.context); // Error: Argument of type 'AddToAccountContext | WelcomeContext' is not assignable to parameter of type 'AddToAccountContext & WelcomeContext'.
}
}
Svar #1
Problemet här är att kompilatorn inte följer korrelationen mellan typen av TemplateClass, typen av context och typen av this.template.
För enkelhetens skull kommer jag att omstrukturera din kod till en version med endast en generisk typparameter K som motsvarar den du kallar Key. Dina typparametrar Template och Context är i huvudsak bara alias för olika typfunktioner på Key, och att ha extra typparametrar gör inte problemet lättare att diskutera eller lösa:
type TemplateInstances =
{ [K in keyof typeof templates]: InstanceType<typeof templates[K]> };
type TemplateContexts =
{ [K in keyof TemplateInstances]: TemplateInstances[K]['context'] };
class EmailService<K extends keyof TemplateInstances> {
templateName: K;
context: TemplateContexts[K];
template: TemplateInstances[K];
constructor(templateName: K, context: TemplateContexts[K]) {
this.templateName = templateName;
this.context = context;
const TemplateClass = templates[this.templateName];
this.template = new TemplateClass(this.context); // error!
//~~~~~~~~~~~ <-----------------> ~~~~~~~~~~~~~
}
}
Jag har definierat hjälptyperna TemplateInstances och TemplateContexts som mappningar från templateName-strängliteraler till motsvarande instanstyp och kontexttyp, så att vi senare kan bara säga TemplateInstances[K] istället för din Template-typparameter, och TemplateContexts[K] istället för din Context-typparameter.
Den här versionen har samma problem som din: kompilatorn klagar på att den får unioner istället för de snitt den ville ha. Men varför?
Typerna på TemplateClass, context och this.template beror på den generiska typparameter K som är begränsad till en unionstyp. Och därför ser kompilatorn var och en av dem som union-liknande i sig själva. Till exempel vet den att TemplateClass är en av två möjliga konstruktörer, och att context är en inmatning som passar för en av dessa två konstruktörer. Men den tappar bort det faktum att context definitivt kommer att passa för TemplateClass. Den oroar sig för att kanske TemplateClass kommer att vara, säg, AddToAccountTemplate-konstruktören medan context kommer att vara av typen WelcomeContext. Den enda inmatning som kompilatorn definitivt skulle se som säker för TemplateClass skulle vara en inmatning som båda möjliga konstruktörer definitivt accepterar; det skulle vara snittet av möjliga indatatyper.
Denna typ av misslyckande med att följa korrelation har länge varit en av TypeScripts smärtpunkter, och fick mig att lämna in microsoft/TypeScript#30581. Före TypeScript 4.6 skulle det enda sättet att gå vidare här antingen vara att skriva mycket redundant kod för att övertyga kompilatorn om typ-säkerheten (vilket ändå inte skulle fungera med generics), eller att använda typförsäkringar för att säga till kompilatorn att inte oroa sig för typ-säkerheten som den ändå inte kunde verifiera.
En sådan typförklaring är det enklaste svaret här; inuti konstruktorn, försäkra att TemplateClass är av följande typ:
const TemplateClass = templates[this.templateName] as
new (c: TemplateContexts[K]) => TemplateInstances[K];
this.template = new TemplateClass(this.context); // okay
Nu finns det inget fel. Du har i princip tagit bort uppgiften att bestämma typen av templates[templateName] från kompilatorn. Allt fungerar nu. Ja, kompilatorn skulle inte kunna se någon skillnad om du ändrade det till något helt fel, som
const TemplateClass = templates[
Math.random() < 0.5 ? "AddToAccountTemplate" : "WelcomeTemplate"
] as new (c: TemplateContexts[K]) => TemplateInstances[K]; // okay
så du behöver vara försiktig när du gör typpåståenden. Men det är enkelt.
TypeScript 4.6 introducerade några förbättringar av generiskt indexerad åtkomstbeteende, särskilt för att hantera korrelerade unionstyper på ett typesäkert sätt (tja, relativt sett i alla fall; se ms/TS#48730).
Det handlar om att refaktorera dina typer till mappade typer och indexera in i dem där saker uttryckligen representeras i termer av en generell nyckeltyp. Ändringarna här innebär att annotera din templates-variabel för att vara av en sådan typ. Här är ett sätt att göra det:
const _templates = {
WelcomeTemplate,
AddToAccountTemplate
};
type TemplateInstances =
{ [K in keyof typeof _templates]: InstanceType<typeof _templates[K]> };
type TemplateContexts =
{ [K in keyof TemplateInstances]: TemplateInstances[K]['context'] };
type TemplateConstructors =
{ readonly [K in keyof TemplateInstances]:
new (c: TemplateContexts[K]) => TemplateInstances[K] };
const templates: TemplateConstructors = Object.freeze(_templates);
Observera att i ovanstående är värdet av templates detsamma (jag använder en mellanliggande _templates-variabel för att underlätta skrivande), men typen av templates representeras på olika sätt. Den skrivs uttryckligen som en avbildning från generisk nyckel K till konstruktorstyp new (c: TemplateContexts[K]) => TemplateInstances[K].
När vi väl gör det, försvinner felet i EmailService:s konstruktor bara:
this.templateName = templateName;
this.context = context;
const TemplateClass = templates[this.templateName];
this.template = new TemplateClass(this.context); // okay!
Härligt! Bara för att understryka att åtgärden beror på den mappade typrepresentationen av templates, bryts saker igen om vi använder en strukturellt identisk vanlig objekttyp:
const stillTemplates: {
readonly WelcomeTemplate: new (x: WelcomeContext) => WelcomeTemplate;
readonly AddToAccountTemplate: new (x: AddToAccountContext) => AddToAccountTemplate;
} = templates; // okay, it's a structrually identical type, but:
// later
const TemplateClass = stillTtemplates[this.templateName];
this.template = new TemplateClass(this.context); // errors came back!
//~~~~~~~~~~~ <-----------------> ~~~~~~~~~~~~
Så, där har du det. Kompilatorn är inte tillräckligt smart för att lista ut att det du gör är säkert. För att fixa det kan du använda en typkonstatering för att ta dig igenom och undertrycka fel, eller så kan du omstrukturera dina typer för att ge den de ledtrådar den behöver för att se logiken i det du gör.
Playground-länk till kod
