Search code examples
typescripttypeerror

Type is not correctly determined for value in key/value pair when key is narrowed down to a single property of an object


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.


Solution

  • 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