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 Money
s 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