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'
Playground
As described in How to dynamically ensure that a function in an object is called only with the params that it accepts?, while externally a mapped type (like typeof obj
here) is enough to have TypeScript correctly detect the correlation between your 2 arguments when you call your doSomething
function, internally this does not work, even after type narrowing the key.
The only way to make TypeScript infer correlated types is using a discriminated union:
type ObjKeys = keyof typeof obj;
// Build a discriminated union from the obj map:
type DiscriminatedUnionObj = {
//^? { key: "a"; value: string; } | { key: "b"; value: number; }
// Mapped type:
[Key in ObjKeys]: {
key: Key;
value: typeof obj[Key]
}
}[ObjKeys] // Indexed access to convert the mapped type into a union
// Take parameters in a single object, possibly immediately destructured
const doSomething = <T extends DiscriminatedUnionObj>({ key, value }: T) => {
if (key === 'a') {
value.toLowerCase(); // Okay
//^? string
} else if (key === 'b') {
value.toExponential(); // Okay
//^? number
}
}
Unfortunately, that means slightly changing your function signature (your parameters are in a single object, possibly immediately destructured).
Playground Link
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