Issue
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
Solution
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')
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
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
Answered By - captain-yossarian
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.