I have defined the following generic type which extracts from a type T the string keys whose value is a number :
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ?
K
: never
: never
}[keyof T];
I try to use this type in a generic function as following :
function setNumberField<T>(item: T, field: StringKeysMatchingNumber<T>): void {
item[field] = 1;
}
But the line item[field] = 1; errors with Type 'number' is not assignable to type 'T[StringKeysMatchingNumber<T>]'.
I have tried a few different things such as narrowing the generic type T in the function to a type which explicitly contains some string keys with value number but this didn't help.
Anyone can see what the problem is ? Here is a TS playground with the code sample and some more details : https://www.typescriptlang.org/play?#code/C4TwDgpgBAKhDOwbmgXigbwLACgpQEMAuKAOwFcBbAIwgCcAaXfagfhIpvqbygGMSiOgEtSAcx74AJhyq06uAL65cAelVQAgvCgQAHpD7AIU2AmABpCCB3oARATtQAPlDtS7uUJDOIrNqHQAZWARcX94AFkCYD4AC1ExADk5egAeOERkSAA+FRxvaBCwsQjo2ITxFK46DJzAzGYoAG0LKFEoAGtrAHsAM1gAXQBadig2-WNSKR0hRKhWJvwYVsHdPSmZslS6BaX8cf38DggAN3p9k-OFHEVm7pB+oYBufL7yUiNhHtIoeAhgNV5AAxYQQAA2UjqAAphMZKCQYAwoH0wZCSMVEmUYvFEkD0jAcgBKEinHrCUzYXhwiCUZqoiFSNboACMr1uQA
T in setNumberField is a black box. Nobody knows, even you, whether T has key with numeric value or not. There is not appropriate constraint. setNumberField allows you to provide even primitive value as a first argument. It means that inside function body, TS is unaware that item[field] is always a numerical value. However, TS is aware about it during function call. So function has two levels of typeings. One - is the function definition, when TS is unable to gues the T type and second one - during function call, when TS is aware about T type and able to infer it.
The easiest way to do it is to avoid mutation. You can return new object. Consider this example:
type TestType = {
a: number,
b?: number,
c: string,
d: number
}
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ?
K
: never
: never
}[keyof T];
const setNumberField = <
Item,
Field extends StringKeysMatchingNumber<Item>
>(item: Item, field: Field): Item => ({
...item,
[field]: 1
})
declare let foo: TestType
// {
// a: number;
// b: string;
// }
const result = setNumberField({ a: 42, b: 'str' }, 'a')
Playground
Please keep in mind, TypeScript does not like mutations. See my article
If you still want mutate your argument, you should overload your function.
type TestType = {
a: number,
b?: number,
c: string,
d: number
}
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ?
K
: never
: never
}[keyof T];
function setNumberField<Item, Field extends StringKeysMatchingNumber<Item>>(item: Item, field: Field): void;
function setNumberField(item: Record<string, number>, field: string): void {
item[field] = 2
}
declare let foo: TestType
const result1 = setNumberField({ a: 42, b: 'str' }, 'a') // ok
const result2 = setNumberField({ a: 42, b: 'str' }, 'b') // expected error
Playground
Function overloading is not so strict. As you might have noticed, this function type definition function setNumberField(item: Record<string, number>, field: string) allows you to use only object where all values are numbers. But this is not the case. This is why I have overloaded this function with another one layer. The bottom one is used for function body. The top one, with StringKeysMatchingNumber controls function arguments.
UPDATE
Why adding a constraint such as
T extends Record<string, number>is not enough to make TS aware of the type ofitem[field]
Consider this:
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ? // This line does not mean that T[K] is equal to number
K
: never
: never
}[keyof T];
This line T[K] extends number means that T[K] is a subtype of number. It can be number & {__tag:'Batman'}. Also, please keep in mind, that StringKeysMatchingNumber might return never and number is not assignable to never:
declare let x: never;
x = 1 // error
Be aware, that calling StringKeysMatchingNumber with static argument like {foo: 42} produces expected result "foo":
type Result = StringKeysMatchingNumber<{ foo: 42 }> // foo
But resolving StringKeysMatchingNumber inside a function body is completely different history. Hover your mouse on Result inside function
See example:
function setNumberField<
T extends Record<string, number>,
Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
type Result = StringKeysMatchingNumber<T> // resolving T inside a function
const value = item[field];
item[field] = 1; // error
value.toExponential // ok
}
item[field] is resolved to T[Field], it is not a number type. It is a subtype of number type. Yuo are still allowed to call toExponential. It is very important to understand that item[field] is a type which has all properties of number type but also may contain some other properties. number is not primitive from TS point of view.
//"toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
type NumberKeys = keyof number
See this:
function setNumberField<
T extends Record<string, number>,
Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
let numSupertype = 5;
let numSubtype = item[field]
numSupertype = numSubtype // ok
numSubtype = numSupertype // expected error
}
numSubtype is assignable to numSupertype. item[field] is assignlable to any variable with number type whereas number is not assignable to item[field].
Final question
Is the way you want to assign a number to item[field] is type safe enough?
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: T[K] extends number ? K : never
}[keyof T];
function setNumberField<
T extends Record<string, number>,
Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
item[field] = 2
}
type BrandNumber = number & { __tag: 'Batman' }
declare let brandNumber: BrandNumber
type WeirdDictionary = Record<string, BrandNumber>
const obj: WeirdDictionary = {
property: brandNumber
}
setNumberField(obj, 'foo')
setNumberField expects a dictionary where each value extends number type. It means that value might be number & { __tag: 'Batman' }. I know, it is weird from developer perspective but not from type perspective. It is just a subtype of number and this technique is used to mock nominal types.
What happens if you assign 2 to item[field] without error ? After calling this function, you expect each value to be BrandNumber but it will not be true.
So, TypeScript does good job here :D
You can find more information about function argument inference in my article
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With