Why can the type of value
not correctly be determined when I narrow key
down to a single possible property of obj
- even though the type of value
is the type of that property in obj
?
const obj = {
a: "FOO",
b: 123,
}
const doSomething = <T extends keyof typeof obj>(key: T, value: typeof obj[T]) => {
if (key === 'a') {
value.toUpperCase();
// Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
}
}
I'm assuming this might even be something that is not (yet) implemented in typescript, but I can't find any information on this problem.
EDIT (to clarify goal further):
I'm not trying to use the value of a property in obj
. I want to set a new value which satisfies the type of a property in obj
. And for some specific keys I want to transform the value beforehand. Something like this:
const setValue = <T extends keyof typeof obj>(key: T, value: typeof obj[T]) => {
if (key === 'a') {
value = value.toUpperCase();
}
obj[key] = value;
}
setValue('a', 'bar'); // {a: 'BAR', b: 123}
setValue('b', 321); // {a: 'BAR', b: 321}
I'm aware that this would be achievable via setters on the properties of obj
. But at this point I don't have any control over the composition of obj
.
The approach here is similar to what ghybs has shown in his answer.
But there are some differences:
The function is not generic anymore. It seems like you don't want to relate the parameter types to the return type in any way. Using a discriminated union instead of a generic type let's us avoid the narrowing issue.
The discriminated union uses tuples instead of an object. This makes it possible to call the function like setValue("b", 123)
without having to use an object. We can use a rest parameter to use the tuple as the type of the parameters and key
and value
can be destructured from there.
type ObjKeys = keyof typeof obj;
type DiscriminatedUnionObj = {
[Key in ObjKeys]: [
key: Key,
value: typeof obj[Key]
]
}[ObjKeys]
const setValue = (...[key, value]: DiscriminatedUnionObj) => {
if (key === 'a') {
value.toLowerCase();
//^? string
} else if (key === 'b') {
value.toExponential();
//^? number
}
obj[key] = value
// ^^^^^^^^ Error: Type 'string' is not assignable to type 'never'
setProp(obj, key, value)
}
const setProp = <T, K extends keyof T, V extends T[K]>(
obj: T, key: K, val: V
) => obj[key] = val
While narrowing the type of value
based on checking the key
works now, we still can't do obj[key] = value
. The compiler eagerly evaluates obj[key]
to be a union of string | number
which makes the assignment fail here.
But with a type-safe helper function setProp
we can still set the value of properties.
The function setValue
can now be called like this:
setValue("a", "some string")
setValue("b", 123)
setValue("a", 123)
// ^^^^^^^^ Error: Type 'number' is not assignable to type 'string'