typescript - Att bygga upp ett infererat objekt fungerar tills det inte gör det
Lekplats | Sandlåda
Låt oss börja i slutet med denna ögonblicksbild som visar alla enhetstester som har klarat sig men markerade i rött, en olycklig förlust av noggrannhet i typer:
Det du ser är en Configurator()-funktion som ger ett sätt att bygga upp en konfiguration och när du är klar anropar du done() och den typade konfigurationen blir tillgänglig. Den goda nyheten är att det nästan fungerar. Det fungerar vid körning (notera den blå texten som visar att alla nycklar - a, b och c - faktiskt är inställda). Men, som du ser i de markerade områdena längst ner, är egenskapen b inte typad medan a och c är det.
Anledningen till detta skrivfel för b kommer från implementationsdetaljer som till viss del är förstådda, men låt oss ta upp dem. Här är Configurator-koden:
export function Configurator<I extends object>(initial?: I) {
let configuration = () => initial || {};
const api = <C extends object>(current: C): IConfigurator<C> => {
return {
set<V, K extends string, KV = { [U in K]: V }>(key: K, value: V) {
const keyValue = ({ [key]: value as V } as unknown) as KV;
const updated = { ...config, ...keyValue };
configuration = (): C & KV => updated;
return api<C & KV>(updated);
},
done() {
return configuration() as C;
},
};
};
return api(initial || {});
}
Gränssnittet IConfigurator<T> returneras vid varje loop av konfiguration med api-ytan och med användning av set() kan nya nyckelvärdespar läggas till när de kommer in med full Typescript-exakthet. Denna del fungerar och det är därför variabeln c kommer ihåg egenskapen "a" (se punkt #1 ovan), och varför variabeln d kommer ihåg både egenskapen "a" (eftersom c visste om den) och egenskapen "c".
Egenskapen "b" (satt på raden markerad som #2) kommer ihåg vid körning på grund av att configuration-parametern tilldelas i Konfigurationens omgivning kontra api-omgivningen. Vi hade ursprungligen bara tilldelat i objektvärdet men vad du ser här är en funktion som returnerar ett värde; vi ändrade detta eftersom Typescript ibland är bättre på att härleda saker när en funktion är involverad. Tyvärr inte här. Konfigurationens typ är fastställd som ett tomt objekt. Eftersom api-ytan är av typen C kan vi kasta konfigurationen till typen C men det betyder att egenskaper som b blir föräldralösa från typ systemets härledning.
Min fråga är tvådelad:
- Finns det ett sätt på vilket vi kan fånga/komma ihåg typen av
configurationså att när vi kallar pådone()-metoden kan vi säkerställa att full typstöd är tillgängligt? - Alternativt, om typen för
konfigurationenhar gått förlorad, finns det ett sätt att återskapa den? Som du kan se från skärmdumpen, vid körning kan egenskaperna och deras typer identifieras ... verkar som om detta borde leda till ett sätt att uppnå fullt typstöd, men försök hittills har misslyckats (se nedan)
Observera: här är försöket vi gjorde för att uppnå #2 ovan.
export type AppendToObject<T, U extends keyof any, V> = {
[K in keyof T | U]: K extends keyof T ? T[K] : V;
};
/** Given a structured run-time object, iterate over keys and append types */
export const inferObject = <T extends object>(v: T) => {
let obj = v;
Object.keys(v).forEach((key) => {
const value = (v as any)[key];
obj = fixup(v, key, value);
});
return obj;
};
function fixup<V, T extends object, K extends string = string>(obj: T, key: K, value: V) {
return { ...obj, [key]: value } as AppendToObject<T, K, V>;
}
Not 2: koden för IConfigurator utelämnades i början och även om den kommer att behöva uppdateras om vi hittar en lösning tror jag inte att det är roten till problemet. Ändå kan jag inte komma på någon anledning till att någon skulle tro mig om detta.
interface IConfigurator<C> {
set<V, K extends string, KV = { [U in K]: V }>(key: K, value: V): IConfigurator<C & KV>;
done(): C;
}
Svar #1
TypeScript har inte stöd för metoder som både returnerar ett värde och begränsar typen av objektet som du anropar metoden på.
Traditionellt (före TS 3.7) skulle jag säga att om du vill att TypeScripts typkontroll ska hålla reda på typerna på rätt sätt, behöver du använda ett rent "fluent builder"-mönster där varje värde i kedjan används exakt en gång. Eller om du använder dem flera gånger (som i ditt c.set()-exempel), behöver du göra metoderna immutabla, så att c.set():s returvärde är det enda som påverkas. TypeScript har traditionellt inte kunnat fånga idén om att en objektsmetod muterar värdets tillstånd.
Sedan TypeScript 3.7 kan du använda försäkringsfunktioner för att skriva en konfigurator där du gör motsatsen; du ignorerar metodens returvärde och fortsätter att återanvända det ursprungliga objektet. Varje gång du anropar c.set() kommer det att begränsa typen av c.
Din nuvarande set()-metod är avsedd att göra båda dessa saker, men TypeScript kan egentligen bara stödja en av dem. (Och den tillståndsbaserade versionen har en irriterande förbehåll)
Den rena byggarversionen fungerar så här med dina befintliga set()-metoder:
let o = Configurator()
.set("a", 5)
.set("b", "foobar")
.set("c", { hello: "world" })
.done();
o.a // okay
o.b // okay
o.c // okay
Här kastar vi bort varje mellanobjekt efter att vi har använt det en gång; vi återanvänder det aldrig.
Det tillståndsbaserade tillvägagångssättet använder påståendefunktioner som introducerades i TypeScript 3.7. Dessa funktioner gör det möjligt att markera void-returande funktioner som att ha en inskränkande effekt på typen av en av deras parametrar... eller i fallet med metoder, en inskränkande effekt på typen av objektet som har metoden. Jag kommer att ändra din set()-metods signatur:
interface IConfigurator<C = {}> {
add<A extends {}>(dictionary: A): IConfigurator<A & C>;
set<V, K extends string, KV = { [U in K]: V }>(
key: K, value: V
): asserts this is IConfigurator<C & KV>;
done(): C;
}
Observera asserts this is... returtyp. Sedan kan du använda konfiguratören så här:
let c: IConfigurator = Configurator();
// ~~~~~~~~~~~~~~~ <-- note this annotation
c.set("a", 5);
c.set("b", "foobar");
c.set("c", { hello: "world" });
const o = c.done();
o.a // okay
o.b // okay
o.c // okay
Varje gång c.set() anropas, smalnar det typen på c, så att när du slutligen anropar c.done(), är returvärdet känt för att ha alla de egenskaper du set:at på det. Frustrerande nog måste du ge en explicit typanteckning till c för att detta ska fungera. Om du utelämnar det får du ett irriterande fel varje gång du anropar set() (se microsoft/TypeScript#36931 för mer information):
let cBad = Configurator();
cBad.set("a", 5); // error!
//~~~~~~ <-- Assertions require every name in the call target
// to be declared with an explicit type annotation.
Lekplatslänk till kod
Svar #2
Jag tror att du stöter på detta problem: Representera typerna av funktionsparametrar som ändras inuti funktionen.
Tyvärr tror jag inte att det finns en enkel lösning på det.
Jag tror att det är anledningen till att de flesta byggare börjar från en redan känd komplett typ.
