Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 660
Zammy Avatar asked Sep 10 '25 18:09

Zammy


2 Answers

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

like image 61
Tobias S. Avatar answered Sep 13 '25 13:09

Tobias S.


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

like image 41
ghybs Avatar answered Sep 13 '25 12:09

ghybs