Skapa en flytande, tillståndsbaserad builder med TypeScript-typsystemet
Jag skulle vilja skapa en builder som kan representera builder.a().b().build() eller builder.b().a().build(), men inte builder.a().build(), builder.b().build() eller builder.a().a().build(), etc.
Självklart kan jag göra valideringen i byggmetoden, men jag skulle vilja att kompilatorn antydde detta (och för att VS Code ska ge autokomplettering). Jag tror att TS-typ systemet kan representera detta med mappade typer, unioner och snitt, men jag kan inte riktigt lista ut det.
Vet någon hur jag skulle kunna göra detta?
Svar #1
Låt oss börja med den enklaste delen, själva implementeringen. Klassen använder this som returtyp. Den verkliga magin kommer att ske med hjälp av en ytterligare typ senare:
class ConcreteBuilder {
a(): this {
return this;
}
b(): this {
return this;
}
build(): string {
return 'foo';
}
}
Nästa behöver vi en generisk typ där vi kan skicka in en funktionstyp och den kommer att byta ut returtypen mot något annat. Du kommer att se varför om en minut. Jag kopierade den här koden från ett annat svar:
type ArgumentTypes<T> = T extends (... args: infer U) => infer R ? U: never;
type ReplaceReturnType<O, N> = (...a: ArgumentTypes<O>) => N;
Nu blir det intressant, här är Builder-typen jag kom på:
type Builder<K extends keyof ConcreteBuilder> = {
[U in K]: U extends 'build' ? ConcreteBuilder[U] : ReplaceReturnType<
ConcreteBuilder[U],
Builder<Exclude<K, U> extends never ? 'build' : Exclude<K, U>>
>
};
Typen har ett generiskt argument K som är begränsat till att vara en nyckel i ConcreteBuilder. Detta kan vara 'a' eller även 'a' | 'b' | 'build'. Vi kommer att använda detta argument för att avgöra vilka metoder som ska vara tillgängliga i den resulterande typen.
För detta använder vi en avbildad typ som itererar över alla nycklar i K. För varje U i K, förutom build-metoden, modifierar vi metodens returtyp.
ConcreteBuilder[U] hänvisar till den ursprungliga funktionstypen för metoden.
Om vi har nått build-metoden (U extends 'build') behåller vi den ursprungliga typen från ConcreteBuilder. Annars använder vi ReplaceReturnType för att bevara de ursprungliga argumenten men ersätta returtypen med:
Builder<Exclude<K, U> extends never ? 'build' : Exclude<K, U>>
Mycket att ta itu med här. Som du kan se ersätter vi returtypen med Builder. Argumenten för detta definierar, som diskuterats, vilka metoder som är tillgängliga. Eftersom detta är returtypen för metoden U, vill vi ta bort U från listan över tillgängliga metoder för att förhindra att den anropas två gånger. Detta görs med typen Exclude. I vårt fall tar vi bort U från unionen av nycklar K.
Eftersom denna rekursiva typ fortsätter tills alla metoder tas bort från K behöver vi också ett basfall. Det är vad den villkorliga typen här är till för. Vi kontrollerar om de återstående nycklarna (Exclude<K, U>) utökar never vilket i huvudsak betyder om de är "tomma". Och om det är fallet returnerar vi en byggare med metoden build.
Slutligen är det enda som återstår builder-funktionen:
function builder(): Builder<Exclude<keyof ConcreteBuilder, 'build'>> {
return new ConcreteBuilder() as any;
}
Det returnerar en Builder med alla metoder utom build. Det behövs en any-typning, eftersom typen av ConcreteBuilder är mindre restriktiv än Builder.
Fullständig kod
type ArgumentTypes<T> = T extends (... args: infer U) => infer R ? U: never;
type ReplaceReturnType<O, N> = (...a: ArgumentTypes<O>) => N;
type Builder<K extends keyof ConcreteBuilder> = {
[U in K]: U extends 'build' ? ConcreteBuilder[U] : ReplaceReturnType<
ConcreteBuilder[U],
Builder<Exclude<K, U> extends never ? 'build' : Exclude<K, U>>
>
};
function builder(): Builder<Exclude<keyof ConcreteBuilder, 'build'>> {
return new ConcreteBuilder() as any;
}
class ConcreteBuilder {
a(): this {
return this;
}
b(): this {
return this;
}
build(): string {
return 'foo';
}
}
builder().a().b().build(); // ok
builder().b().a().build(); // ok
builder().build(); // error
builder().a().build(); // error
builder().b().build(); // error
builder().a().a().build(); // error
builder().b().b().build(); // error
builder().a().b().b().build(); // error
builder().a().a().b().build(); // error
Lekplats
