Generiska funktioner för olika datatyper i TypeScript
Jag försöker skapa generiska funktioner för att lägga till, ta bort och redigera värden som lagras i ett state-objekt. Dessa funktioner kommer att ta en lista med objekt som utökar en basklass. Idén är att inte skriva en specifik version av funktionen för varje samling. Och även om detta fungerar, är problemet att jag försöker göra detta där TS inte varnar för potentiella problem.
Jag provade flera saker inklusive keyof typ, typeof, etc.
interface IFee {
id?: string;
amount: number;
description?: string;
}
interface IProduct {
id?: string;
name: string;
description?: string;
price: number;
}
interface Client {
fees: IFee[];
products: IProduct[];
}
class ClientState {
private state: Client = {
fees: [],
products: [],
}
addItem<T>(collection: keyof Client, data: T): T[] {
const newData: T[] = [
...this.state[collection], // < Warning from TS
data
];
this.state[collection] = newData; // < Warning from TS
return newData;
}
removeItem<T>(collection: keyof Client, id: string): T[] {
this.state[collection] = this.state[collection]?.filter((i: T) => i.id !== id); // < Warning from TS
return this.state[collection]; // < Warning from TS
}
}
const state = new ClientState();
const allFees: IFee[] = state.addItem<IFee>('fees', { amount: 10 });
console.log('allFees', allFees);
state.addItem<IProduct>('products', { id: '1', name: 'Sample Prod 1', price: 10 });
state.addItem<IProduct>('products', { id: '2', name: 'Sample Prod 2', price: 10 });
state.addItem<IProduct>('products', { id: '3', name: 'Sample Prod 3', price: 10 });
const allProducts: IProduct[] = state.addItem<IProduct>('products', { id: '4', name: 'Sample Prod 4', price: 10 });
console.log('allProducts', allProducts);
state.removeItem<IProduct>('products', '2');
const remainingProd = state.removeItem<IProduct>('products', '3');
console.log('remainingProd', remainingProd);
Redigera: Tog bort onödigt innehåll från exempelcode. Redigera: Tog bort orelaterad kod.
Svar #1
Du måste ge kompilatorn tillräcklig information så att collection-nyckelnamnet och data-typen är lämpliga för varandra. I din version skulle collection lätt kunna vara "products", men data är IFee och du hävdar att du returnerar en IFee[], men den faktiska samlingen som returneras skulle vara IProduct[] (möjligen med en IFee tillagd där 😬). Kompilatorn har rätt att vara missnöjd.
I en perfekt värld skulle du kunna ändra den generiska samtals-signaturen lite:
addItem<K extends keyof Client>(collection: K, data: Client[K][number]): Client[K] {
/* impl */
}
Det betyder att typen av data bör vara samma som om du indexerar i ett Client-objekt med en nyckel av typen K (samma som collection) och sedan indexerar i det med en nyckel av typen number. Eftersom Client[K] är en array-typ, är Client[K][number] elementtypen för den arrayen.
Detta hjälper verkligen uppringare; du behöver inte längre manuellt ange datatypen:
const allFees: IFee[] = state.addItem('fees', { amount: 10 }); // okay
Och du kommer att varnas om du försöker skicka fel data:
state.addItem('products', { id: 'oops', amount: 100 }); // error!
// Argument of type '{ id: string; amount: number; }' is not
// assignable to parameter of type 'IProduct'.
Men det finns fortfarande kompilatorfel i implementeringen:
addItem<K extends keyof Client>(collection: K, data: Client[K][number]): Client[K] {
const newData = [...this.state[collection], data];
this.state[collection] = newData; // error 🙁
return newData; // error 🙁
}
Problemet här är att kompilatorn inte kan ”se” korrelationen mellan this.state[collection]-typen och data-typen för godtycklig K. Det är ett för komplicerat förhållande. Denna generella situation är ämnet för microsoft/TypeScript#30581, där det beskrivs som att hantera korrelerade unioner; collection är begränsad till unionstypen "fees" | "products" och data är begränsad till unionen IFee | IProduct, men dessa är inte oberoende av varandra, och kompilatorn är ofta omedveten om detta beroende.
Den rekommenderade lösningen på detta, som beskrivs i microsoft/TypeScript#47109, är att refaktorera typerna för att använda en enklare kartläggningstyp och sedan uttrycka saker i termer av denna kartläggningstyp. Du kan läsa det ärendet för detaljer.
I fallet med detta exempelcode ser det ut så här:
type ClientData = {
fees: IFee,
products: IProduct
}
type Client<K extends keyof ClientData = keyof ClientData> = {
[P in K]: ClientData[P][]
}
Så ClientData är den enklare kartläggningstypen, och Client är en distributiv objekttyp som kartlägger data från ClientData till arrayer. Client i sig är ekvivalent med din tidigare version, medan Client<K> för en särskild generisk K kan användas för att bara titta på nyckel-värde-kartläggningen för en viss K.
Nu kan vi skriva addItem() och removeItem() så här:
addItem<K extends keyof ClientData>(collection: K, data: ClientData[K]): ClientData[K][] {
const state: Client<K> = this.state;
const coll: ClientData[K][] = state[collection];
const newData = [...coll, data];
state[collection] = newData;
return newData;
}
removeItem<K extends keyof ClientData>(collection: K, id: string): ClientData[K][] {
const state: Client<K> = this.state;
const coll: ClientData[K][] = state[collection];
state[collection] = coll.filter((i => i.id !== id));
return state[collection];
}
Anropssignaturerna är nu skrivna i termer av ClientData, men det fungerar på samma sätt från anroparens sida.
När det gäller implementeringen behöver kompilatorn hjälp med att känna igen tilldelbarheten av de olika värdena som är inblandade: Vi kopierar this.state, känt för att vara av typen Client, till state av typen Client<K>, och denna tilldelning verifieras som säker. Vi kopierar också state[collection], känt för att vara av typen Client<K>[K] till coll av typen ClientData[K][], och denna tilldelning verifieras också som säker. Utrustade med detta kan vi utföra arrayoperationer på coll och ändå få ClientData[K][], och sedan kan vi tilldela detta till state[collection], vars typ Client<K>[K] ses som ekvivalent med ClientData[K][] enligt definitionen av Client.
Om du försöker effektivisera detta genom att tilldela direkt till this.state[collection], kommer du att märka att det inte fungerar. Kompilatorn blir förvirrad med typer som (Client<keyof ClientData>[K][number])[] och klagar. De ovanstående tilldelningarna var specifikt utvalda för att hjälpa kompilatorn att verifiera typ-säkerhet.
Så där har du det. Ärligt talat skulle jag inte förvänta mig att de flesta utvecklare skulle kunna lista ut hur man leder kompilatorn genom detta typanalysfält. Och det finns troligen modifieringar av detta exempel där det inte finns något sätt att göra det. I sådana fall kan du alltid falla tillbaka på en klok användning av en typkontroll. Så länge du kan övertyga dig själv om att [...this.state[collection], data] definitivt kommer att vara av typen Client[K] (det vill säga, det är en array av samma typ av element som data), kan du bara hävda det och gå vidare:
addItem<K extends keyof Client>(collection: K, data: Client[K][number]): Client[K] {
const newData = [...this.state[collection], data] as Client[K]; // just assert this
this.state[collection] = newData;
return newData;
}
Typantaganden är ett erkännande av att du vet mer än kompilatorn om typen av ett uttryck och ett påstående om att du tar ansvar för sanningshalten i det påståendet, även inför möjliga framtida förändringar av koden. Denna ansvarsöverföring är anledningen till att typantaganden bäst undviks där det är möjligt.
Playground-länk till kod
Svar #2
Det här är bara mina två ören, i hopp om att någon som @jcalz kan förbättra det.
Tyvärr har TypeScript problem med generiska typer i fall som detta, där du använder en nyckel i ett objekt för att bestämma typer, eftersom det resulterar i unioner och snitt som egentligen inte representerar vilka begränsningar som finns på värdena.
Du kan prova detta:
class ClientState {
private state: Client = {
fees: [],
products: [],
};
addItem<CollectionKey extends keyof Client>(
collection: CollectionKey,
data: Client[CollectionKey][number]
): Client[CollectionKey] {
(this.state[collection] as Client[CollectionKey][number][]).push(data);
return this.state[collection];
}
removeItem<CollectionKey extends keyof Client>(
collection: CollectionKey,
id: string
): Client[CollectionKey] {
(this.state[collection] as Client[CollectionKey][number][]) = (
this.state[collection] as Client[CollectionKey][number][]
).filter((i) => i.id !== id);
/* Or:
const index = this.state[collection].findIndex(
(i: Client[CollectionKey][number]) => i.id !== id
);
this.state[collection].splice(index, 1);
*/
return this.state[collection];
}
}
Detta använder samlingen key som generisk, vilket gör att typerna kan härledas vid användning från samlingsnyckeln. Det kräver tyvärr vissa typkonverteringsknep för att få det att fungera. Alternativ är @ts-ignore-kommentarer eller olika metoder.
Observera att beteendet hos arrayerna ändras något, eftersom den interna arrayen returneras, inte en kopia skapad i addItem.
Då kan funktionerna anropas så här:
state.addItem("products", {
id: "1",
name: "Sample Prod 1",
price: 10,
});
state.removeItem("products", "2");
