typescript - Härleda typer från stränglitteraler i unioner
Säg att jag vill hantera tjänster från olika moduler i ett projekt inom en enda funktion, och beroende på vilken typ av data jag får, anropar jag en annan tjänst.
Till exempel, jag har dessa två DTO:er:
class UserDto {
constructor (name: string) {
this.name = name
}
public name: string;
}
class CarDto {
constructor (brand: string) {
this.brand = brand
}
public brand: string;
}
Och dessa två tjänster:
class UserService {
create(val: UserDto) {
// Create the user
return 'foo';
}
}
class CarService {
create(val: CarDto) {
// Create the car
return 'bar';
}
}
Ganska grundläggande saker. Sedan kan jag få en tjänst som kan hantera anropen beroende på vilken typ av data jag får. För det skapar jag en unionstyp som ska ta reda på vilken typ av data jag får och vilken tjänst jag ska anropa, låt oss säga något i stil med detta:
type DataType =
| {
data: 'user';
dto: UserDto;
service: 'userService'
}
| {
data: 'car';
dto: CarDto;
service: 'carService'
}
Slutligen vill jag samla allt detta i en funktion inom tjänsten som hanterar dessa två underservicer på detta sätt:
class MainService {
constructor (
private userService: UserService,
private carService: CarService,
) {}
serviceManager(val: DataType) {
// Call the right service here!
}
}
Vad jag inte vill göra är att kontrollera varje egenskap om det är den jag vill ha. Detta är vad jag skulle vilja undvika:
serviceManager(val: DataType) {
const name = val.data;
if (name === 'user') this.userService.create(val.dto);
}
Istället skulle jag föredra en metod där TypeScript skulle härleda vilken tjänst jag får och vilken information jag skickar till nämnda tjänst. Så här är vad jag trodde borde fungera (spoiler, det gör det inte):
serviceManager(val: DataType) {
const func = this[val.service];
func.create(val.dto);
// Argument of type 'UserDto | CarDto' is not assignable to parameter of type 'UserDto & CarDto'.
// Type 'UserDto' is not assignable to type 'UserDto & CarDto'.
// Property 'brand' is missing in type 'UserDto' but required in type 'CarDto'.
}
Slutligen bestämde jag mig för att ta en annan väg och inte ens använda deltjänster. Men jag har försökt hitta en lösning på detta problem även om det inte längre är relevant för det projekt jag arbetar med.
Skulle det vara möjligt att göra något liknande med TypeScript med ett annat sätt att deklarera typen DataType eller är det helt enkelt omöjligt? Kanske borde jag ha använt generics när jag deklarerade funktionerna inom delservicen?
Svar #1
TypeScript stöder inte direkt det jag kallar "korrelerade unionstyper". Inuti din implementation av serviceManager() är val-parametern av unionstypen DataType. Därför är också this[val.service] och val.dto av unionstyper (UserService | CarService respektive UserDto | CarDto). Men TypeScript inser inte och kan inte spåra att dessa unioner är "korrelerade" på ett sådant sätt att, till exempel, om this[val.service] är av typen UserService så kommer val.dto vara av typen UserDto. Kompilatorn tror kanske att this[val.service] är av typen UserService medan val.dto är av typen CarDto. Och du får ett fel. Detta allmänna problem diskuteras i microsoft/TypeScript#30581.
Den rekommenderade metoden för att hantera detta beskrivs i detalj i microsoft/TypeScript#47109. Istället för att använda korrelerade unioner bör din kod omarbetas för att använda några grundläggande nyckel-värde-mappningstyper, och generiska index in i dessa typer, samt generiska index i mappade typer över dessa typer. För din kod skulle jag vara benägen att omarbeta den på detta sätt:
interface DtoMap {
user: UserDto,
car: CarDto
}
type ServiceMap =
{ [K in keyof DtoMap]: { create(val: DtoMap[K]): string } }
type DataType<K extends keyof DtoMap> = { [P in K]:
{ data: P, dto: DtoMap[P] }
}[K]
Typen DtoMap är den "grundläggande nyckel-värde-mappingtypen" du behöver, som mappar från data-egenskapen i din avsedda DataType-union, till motsvarande DTO-typ.
Då beskriver ServiceMap ett objekt av tjänster som motsvarar dessa DTO-typer.
Och DataType<K> är en distributiv objekttyp sådan att DataType<keyof DtoMap> är en union liknande din ursprungliga DataType-typ (förutom att vi inte behöver service här; vi kan bara skriva ett services-objekt av typen ServiceMap istället). Om du skickar en enda nyckel som K, får du endast den medlemmen av DataType-unionen.
Nu kan vi göra serviceManager() generisk i K begränsad till nycklarna i DtoMap:
serviceManager<K extends keyof DtoMap>(val: DataType<K>) {
const services: ServiceMap = {
car: this.carService,
user: this.userService
}
const func = services[val.data];
func.create(val.dto);
}
Indatan val är av typen DataType<K>, vilket betyder att val.data kommer att avgöra vilken av unionens medlemmar anropet gäller. services-objektet är bevisbart av typen ServiceMap, och så const func=services[val.data] betraktas som den generiska typen { create(val: DtoMap[K]): string }. Det innebär att func.create är av typen (val: DtoMap[K]) => string. Eftersom val.dto är av typen DtoMap[K], betyder det att du får anropa func.create(val.dto).
Lekplatslänk till kod
