Typescript object keys from generic parameters

Issue

I am attempting to create a function that accepts an array of objects and then one or more parameters that specify the properties which will be used on that array.

For example, either of these:

update([{ name: 'X', percentage: 1, value: 2 }], 'percentage', 'value');
update([{ name: 'X', foo: 1, bar: 2 }], 'foo', 'bar');

Attempt 1:

function update<T extends { name: string }, A extends keyof T, B extends keyof T>(data: T[], a: A, b: B) {
    for (const row of data) {
        console.log(row.name, row[a] * row[b]);
        row[a] = row[a] * row[b];
    }
}
  • When I try to use row[a] as a number I get an error because the type is T[A] and seemingly it doesn’t know that’s a number
  • When I try to assign a number to row[a] I get Type 'number' is not assignable to type 'T[A]'.(2322)

Attempt 2:

type Entry<A extends string, B extends string> = { name: string } & Record<A | B, number>;

function update<T extends Entry<A, B>, A extends string, B extends string>(data: T[], a: A, b: B) {
    for (const row of data) {
        console.log(row.name, row[a] * row[b]);
        row[a] = row[a] * row[b];
    }
}
  • This fixes the issue when using row[a], it seems to be ok with me using it as a number
  • But I still get Type 'number' is not assignable to type 'T[A]'.(2322) when trying to assign a number to row[a] (or anything for that matter)
    • Suprisingly, even removing the { name: string } from Entry doesn’t fix this, typescript still won’t allow me to assign anything here for some reason

This seems like a fairly common thing to what to do in JS, but I have no idea how to make Typescript understand it.

Solution

You definitely need something like your Entry<A, B> definition to represent something which is known to have a number property at the A and B keys. Just saying that A and B are keyof typeof data isn’t enough, because for all the compiler knows, there might be non-numeric properties at keyof typeof data (indeed, the name property is non-numeric).

Furthermore you don’t want to have data be of generic type T extends Entry<A, B>. That constraint is an upper bound, so T could be something very specific like {name: string, pi: 3.14, e: 2.72} with properties of number literal type. The only value assignable to the type 3.14 is 3.14, so the compiler correctly prevents you from assigning row[a] * row[b] to row[a]. So we just want data to be of type Entry<A, B> and not some unknown subtype of Entry<A, B>. That gives us this:

function update<A extends string, B extends string>(data: Entry<A, B>[], a: A, b: B) {
  for (const row of data) {
    console.log(row.name, row[a] * row[b]);  
    row[a] = row[a] * row[b]; // error!  Type 'number' is not assignable to type 'Entry<A, B>[A]'
  }
}

which still breaks on the assignment. The compiler is unable to see that number is assignable to Entry<A, B>[A]. Something about the intersection is confusing it. I think this is a limitation of TypeScript (although someone could try to argue that if A is "name" then Entry<A, B>[A] is of type number & string, also known as never, and thus the compiler is correct to warn you that number is not necessarily assignable to Entry<A, B>[A], but this is pretty pedantic and not particularly useful in most situations, in my opinion). I haven’t found a particular issue in GitHub talking about it.

On the other hand, the compiler can see that number is assignable to Record<A, number>[A]. Since Entry<A, B> is a subtype of Record<A, number>, we can then widen row to Record<A, number> safely (modulo the above "name" pedantry above) by assigning it to a new variable with an appropriate type annotation:

function update<A extends string, B extends string>(data: Entry<A, B>[], a: A, b: B) {
  for (const row of data) {
    console.log(row.name, row[a] * row[b]);
    const r: Record<A, number> = row; // okay
    r[a] = row[a] * row[b];
  }
}

And now everything works as desired:

update([{ name: 'X', percentage: 1, value: 2 }], 'percentage', 'value');
update([{ name: 'X', foo: 1, bar: 2 }], 'foo', 'bar');

Playground link to code

Answered By – jcalz

Answer Checked By – Candace Johnson (AngularFixing Volunteer)

Leave a Reply

Your email address will not be published.