You'll need to give the compiler enough information that the collection key name and the data type are appropriate for each other. In your version, the collection could easily be "products" but data is IFee and you're claiming to return an IFee[], but the actual collection returned would be IProduct[] (possibly with an IFee added in there 😬). The compiler is right to be unhappy.
In a perfect world you could change the generic call signature a bit:
addItem<K extends keyof Client>(collection: K, data: Client[K][number]): Client[K] {
/* impl */
}
That's saying the type of data should be the same as if you index into a Client object with a key of type K (the same as collection) and then index into that with a number key. Since Client[K] is an array type, then Client[K][number] is the element type for that array.
This certainly helps callers; you don't need to manually specify the data type anymore:
const allFees: IFee[] = state.addItem('fees', { amount: 10 }); // okay
And you'll get warned if you try to pass the wrong data:
state.addItem('products', { id: 'oops', amount: 100 }); // error!
// Argument of type '{ id: string; amount: number; }' is not
// assignable to parameter of type 'IProduct'.
But there are still compiler errors in the implementation:
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 🙁
}
The problem here is that the compiler cannot "see" the correlation between the this.state[collection] type and the data type for arbitrary K. It's too complicated of a relationship. This general situation is the topic of microsoft/TypeScript#30581, where it's described as dealing with correlated unions; collection is constrained to the union type "fees" | "products" and data is constrained to the union IFee | IProduct, but these are not independent of each other, and the compiler is often unaware of this dependence.
The recommended fix to this, as described in microsoft/TypeScript#47109, is to refactor the types to use a simpler mapping type and then express things in terms of this mapping type. You can read that issue for details.
In the case of this example code it looks like:
type ClientData = {
fees: IFee,
products: IProduct
}
type Client<K extends keyof ClientData = keyof ClientData> = {
[P in K]: ClientData[P][]
}
So ClientData is the simpler mapping type, and Client is a distributive object type that maps the data from ClientData to arrays. Client by itself is equivalent to your prior version, while Client<K> for a particular generic K can be used to look at just the key-value mapping for a particular K.
Now we can write addItem() and removeItem() like this:
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];
}
The call signatures are now written in terms of ClientData, but it works just the same from the caller's side.
As for the implementation, the compiler needs help recognizing the assignability of the various values involved: We copy this.state, known to be of type Client, to state of type Client<K>, and this assignment is verified as safe. We also copy state[collection], known to be of type Client<K>[K] to coll of type ClientData[K][], and this assignment is also verified as safe. Armed with these, we can perform array operations on coll and still end up with ClientData[K][], and then we can assign this to state[collection], whose type Client<K>[K] is seen as equivalent to ClientData[K][] as per the definition of Client.
If you try to streamline this by assigning to this.state[collection] directly, you'll find that it doesn't work. The compiler gets confused with types like (Client<keyof ClientData>[K][number])[] and complains. The above assignments were specifically curated to help the compiler verify type safety.
So there you go. Honestly I wouldn't expect most developers to figure out how to lead the compiler through this type analysis minefield. And there are probably modifications to this example where there is no way to do it. In such cases, you can always fall back to the judicious use of a type assertion. As long as you can convince yourself that [...this.state[collection], data] will definitely be of the type Client[K] (that is, it's an array of the same type of elements as data), then you can just assert it and move on:
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;
}
Type assertions are an admission that you know more than the compiler about the type of some expression and a claim that you are taking on the responsibility for the veracity of that assertion, even in the face of possible future changes to the code. That responsibility shift is why type assertions are best avoided where possible.
Playground link to code
IBaseEntitypart is your concern, right?editItem()implementation is concerning me. If you don't find a value with the relevantid, then you would be putting aPartial<T>into a collection of typeT[], which is not good. And if you do find a value, then you are going to add the updated version to the end of the array without removing the existing one. Could you removeeditItem()from your code so that people answering your question don't have to fix it? Or could you fix it?