3

I'm trying to create a generic functions for adding, removing, and editing values stored in a state object. These functions will take a list of objects that extend a base class. The idea is not to write a specific version of the function for each collection. And although this works, the problem is I'm trying to do this where TS does not warn about potential issues.

I tried several things including keyof type, 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);

Edit: Removed fluff from sample code. Edit: Removed unrelated code.

6
  • Would you consider this to be a reasonable minimal reproducible example? I don't think the IBaseEntity part is your concern, right? Commented Sep 5, 2022 at 0:48
  • Your editItem() implementation is concerning me. If you don't find a value with the relevant id, then you would be putting a Partial<T> into a collection of type T[], 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 remove editItem() from your code so that people answering your question don't have to fix it? Or could you fix it? Commented Sep 5, 2022 at 0:49
  • 1
    In any case, I think the underlying issue here is one of correlated unions as discussed in ms/TS#30581. The recommended fix for this, as per ms/TS#47109, is to refactor to a particular generic form where the compiler can follow the correlation. You really have to curate the types in a specific way. For my version of your example code it looks like this. Does that fully address your question? If so I can write up an answer explaining it; if not, what am I missing? Commented Sep 5, 2022 at 0:52
  • @jcalz Yes removing IBaseEntity would be OK. The idea there would be that the expected collection would be of object that extend that type. However, as you indicated, it's not necessary. The editItem (and in fact the entire thing) is not the final implementation - sorry for the confusion. Your implementation is what I was looking for. There are roughly 20 collection types (and maybe more to in the future) and the sample you provided will work with each of them. Thank you! Commented Sep 5, 2022 at 12:52
  • 1
    Sure thing. I removed the code as per your suggestion. Thanks again! Commented Sep 5, 2022 at 15:54

2 Answers 2

3

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

Sign up to request clarification or add additional context in comments.

2 Comments

This is a perfect answer. It works great. Thank you very much for taking the time to explain!
Fantastic answer! (as always :) )
0

This is just my 2 cents, in the hope that someone like @jcalz can improve on it.

TypeScript unfortunately struggles with generics in cases like this, where you are using a key of an object to determine types, as it results in unions and intersections that don't actually represent what constraints are on the values.

You could try this:

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];
  }
}

This uses the collection key as the generic, allowing the types to inferred at usage from the collection key. It unfortunately requires some casting hacks to get it to work. Alternatives are @ts-ignore comments or different methods.
Note that the behavior of the arrays is modified slightly, as the internal array is returned, not a copy created in addItem.

Then, the functions can be called like this:

state.addItem("products", {
  id: "1",
  name: "Sample Prod 1",
  price: 10,
});

state.removeItem("products", "2");

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.