1

I would to know how to subtract identical object values in typescript using Generics and type-safe, based on: subtracting identical object values javascript

    const subtractObjects = <T extends Record<String, number>>(objA: T, objB: T): T =>
      Object.keys(objA).reduce((a, k) => {
        a[k] = objA[k] - objB[k];
        return a;
      }, {});

I receive the error:

Type '{}' is not assignable to type 'T'.
  '{}' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Record<string, number>'.
Émerson Felinto
  • 433
  • 7
  • 18
  • What is `removeNullObjectValues()`? Please provide a [mre] that clearly demonstrates the issue you are facing. Ideally someone could paste the code into a standalone IDE like [The TypeScript Playground (link here!)](https://tsplay.dev/wX79QW) and immediately get to work solving the problem without first needing to re-create it. So there should be no pseudocode, typos, unrelated errors, or undeclared types or values. – jcalz Feb 26 '22 at 20:50
  • What you are doing is not safe; there are types that extend `SimpleObject` whose properties are narrower than `number`, like [this](https://tsplay.dev/wOJvRW). You cannot claim that `objA[k] - objB[k]` will be a valid property value for `T`. You should probably switch to something like [this](https://tsplay.dev/mL4Y2m) where only the keys are generic and the value is always of type `number`. Does that make sense and work for you? If so I can write up an answer; if not, please tell me what I'm missing. – jcalz Feb 26 '22 at 20:54
  • work as charm! please, go ahead and share this code as the answer! [this link](https://tsplay.dev/mL4Y2m). I realize I don't need the interface "SimpleObject" at all or could use something like "Record". You can refer to it in the answer. – Émerson Felinto Feb 27 '22 at 01:36

1 Answers1

0

When you have a generic type parameter T constrained to Record<string, number>, it doesn't necessarily mean that every property of T will have a value type that's exactly number. It could well be something that extends number, such as a numeric literal type like 123 or a union of such types like 0 | 1. (See Why can't I return a generic 'T' to satisfy a Partial<T>? for a related discussion.) For example:

type Money = {
  denomination: 1 | 5 | 10 | 20 | 50 | 100
}
const twenty: Money = { denomination: 20 };
const one: Money = { denomination: 1 };
const nineteen = subtractObjects<Money>(twenty, one);
/* const nineteen: Money */
console.log(nineteen.denomination) // 1 | 5 | 10 | 20 | 50 | 100 at compile time
// but 19 at runtime

Here, the type Money has a single known property denomination whose value must be one of a particular set of numbers. And since Money extends Record<string, number>, you are allowed to pass two Moneys into subtractObjects(). And according to the call signature for subtractObjects(), that means a Money will come out. The compiler says that nineteen is valid Money. But if you check at runtime, its denomination property is not one of the allowed values. You've accidentally produced counterfeit currency.

If it's not clear from that code example why counterfeit currency is a problem, then note that once you have a situation where the compiler is mistaken about the type of a value, it can lead to runtime errors:

function moneyName(m: Money) {
  return {
    1: "One",
    5: "Five",
    10: "Ten",
    20: "Twenty",
    50: "Fifty",
    100: "One Hundred"
  }[m.denomination]
}
console.log(moneyName(one).toUpperCase()) // ONE
console.log(moneyName(twenty).toUpperCase()) // TWENTY
console.log(moneyName(nineteen).toUpperCase()) //  RUNTIME ERROR! undefined

Anyway that means you cannot be sure that subtractObjects() will produce a valid T output. The error message 'T' could be instantiated with a different subtype of constraint 'Record<string, number>' is saying that the output type of reduce() is not known to be assignable to whatever T will turn out to be, and that even though it will produce a Record<string, number>, not every Record<string, number> will work for every T.


In order to make this type safe, I'd say that you should change subtractObjects() to be generic in the set of keys K of the objA and objB inputs. And objA, objB, and the output type will all be of type Record<K, number>. So there's no way for the input type to have properties narrower than number. It could look like this:

const subtractObjects = <K extends string>(
  objA: Record<K, number>,
  objB: Record<K, number>
) =>
  (Object.keys(objA) as K[]).reduce((a, k) => {
    a[k] = objA[k] - objB[k];
    return a;
  }, {} as { [P in K]: number });

This compiles with no error.

Here we've had to make a few type assertions. One is that we are assuming that Object.keys(objA) will produce an array of type K[]. It's technically true that Object.keys(objA) might produce more keys than the compiler knows about (See Why doesn't Object.keys return a keyof type in TypeScript? for more information) but we're making this simplifying assumption here. You can always revisit that assumption if it turns out that it causes trouble.

Another is that we are claiming that the initial empty {} object in reduce() is already of type Record<K, number> (equivalent to {[P in K]: number}, which will turn out to display the type more nicely than Record<K, number>). This is, of course, not true. But when reduce() is done running then the object will no longer be empty and the output will be of type Record<K, number>, so the assertion is mostly harmless.

Okay, let's see it in action:

const nineteen = subtractObjects(twenty, one);
/* const nineteen: { denomination: number; } */
console.log(nineteen.denomination) // number at compile time, 
// 19 at runtime, no problem now!

Now the value nineteen is no longer considered to be of type Money. The compiler says it's of type {denomination: number}, so the fact that nineteen.denomination === 19 is fine; no fraud is being committed because nobody is claiming that nineteen is Money.

And so now you'll get a helpful compiler error if you try to hand someone phony currency:

moneyName(nineteen).toUpperCase()
// -----> ~~~~~~~~
// Argument of type '{ denomination: number; }' is not 
// assignable to parameter of type 'Money'.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360