Reputation: 8647
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');
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];
}
}
row[a]
as a number I get an error because the type is T[A]
and seemingly it doesn't know that's a numberrow[a]
I get Type 'number' is not assignable to type 'T[A]'.(2322)
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];
}
}
row[a]
, it seems to be ok with me using it as a numberType 'number' is not assignable to type 'T[A]'.(2322)
when trying to assign a number to row[a]
(or anything for that matter)
{ name: string }
from Entry
doesn't fix this, typescript still won't allow me to assign anything here for some reasonThis seems like a fairly common thing to what to do in JS, but I have no idea how to make Typescript understand it.
Upvotes: 3
Views: 2078
Reputation: 341
You can create Mapped type (https://www.typescriptlang.org/docs/handbook/2/mapped-types.html) to address number values, and then add {name: string}
. Unfortunately you have to cast multiplication result to T[keyof T]
as TS cannot figure out that only number can be assigned to non name
fields.
update([{ name: 'X', percentage: 1, value: 2 }], 'percentage', 'value');
update([{ name: 'X', foo: 1, bar: 2 }], 'foo', 'bar');
type NumberValues<Type> = {
[Property in keyof Omit<Type, 'name'>]: number;
};
function update<T extends NumberValues<T> & {name: string}>(data: T[], a: keyof T, b: keyof T) {
for (const row of data) {
const aValue = row[a];
console.log(row.name, row[a] * row[b]);
row[a] = row[a] * row[b] as T[keyof T];
}
}
From there you can modify update
method to accept multiple params of keyof T
type like below:
function update<T extends NumberValues<T> & {name: string}>(data: T[], ...args: (keyof T)[]) {
for (const row of data) {
console.log(row.name, row[args[0]] * row[args[1]]);
row[args[0]] = row[args[0]] * row[args[1]] as T[keyof T];
}
}
If you want to avoid casting, you can save extracted row to variable which you can type, similarly to @jcalz solution:
function update<T extends NumberValues<T> & {name: string}>(data: T[], ...args: (keyof T)[]) {
for (const row of data) {
const r: {[property in keyof T]: number} = row;
r[a] = row[a] * row[b];
}
}
I'm not sure if you were aiming for that, but proposed solution allows you to add multiple fields to object, and pass not all keys, so for example:
update([{ name: 'X', percentage: 1, value: 2, anotherVal: 5 }], 'percentage', 'value');
will work as well.
Upvotes: 1
Reputation: 328362
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');
Upvotes: 1