3

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.

8
  • 1
    I've noticed that TS doesn't like letting you write to a generic A property of something of the form Record<A, Foo> & Bar, but it's fine with just Record<A, Foo>. So my suggestion would be to do something like this. Does that work for you or am I missing something? Commented Jun 8, 2022 at 18:06
  • @jcalz I was hoping there was a way to do this without casts but if not that's certainly a useful option that I hadn't thought of. Commented Jun 9, 2022 at 10:29
  • Can you explain what you mean by “cast” here? That’s a somewhat ambiguous term and TS doesn’t have anything called “casting” per se. There are type assertions (this is what people normally mean by “cast” but I didn’t use any in my example) and type annotations (this is what I used and I suppose you could call this an “upcast”). Commented Jun 9, 2022 at 11:38
  • By cast I mean any use of as, because it means you're having to force something because typescript doesn't have enough information - I always try to give typescript that information, if possible. Commented Jun 9, 2022 at 11:42
  • 1
    No, it widens the type. Widening is the same as upcasting and is generally safe; narrowing is the same as downcasting and is often unsafe (unless you do some sort of check first). Every {name: string} & Record<A | B, number> is also a Record<A, number> but not vice versa, so the latter type is wider than the former. Anyway I'm happy to write up an answer explaining my approach, terminology notwithstanding. Commented Jun 9, 2022 at 14:23

2 Answers 2

1

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

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

Comments

1

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.

2 Comments

Oh! I was trying to do something like the Omit but I was trying to use Exclude and I couldn't figure out how to make it work with keyof. Shame we still have to cast though.
You can use same trick as @jcalz proposed, and extract row to separate variable which will be typed accordingly: const r: {[property in keyof T]: number} = row; r[a] = row[a] * row[b];.

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.