Search code examples
javascriptreactjstypescripttypesdependent-type

How to guarantee that passed object key is callable using Typescript?


I'm trying to create a React higher-order component for a specific use-case, the problem is boiling down to the following:

function sample<TObj, P extends keyof TObj, F extends keyof TObj>(
  obj: TObj,
  prop: P,
  setProp: TObj[F] extends (value: TObj[P]) => void ? F : never
) {
  obj[setProp](obj[prop]);
}

I want to be able to pass an object, a string which should be a key of that object, and another key of that object but which is required to be a function.

This can be simplified further as such:

function sample2<TObj, F extends keyof TObj>(
  obj: TObj,
  setProp: TObj[F] extends () => void ? F : never
) {
  obj[setProp]();
}

It seems to me that because I use the conditional type, it can be guaranteed that obj[setProp] will be a function but I get the error: enter image description here

This expression is not callable.
  Type 'unknown' has no call signatures.ts(2349)

As can be seen below, the function will error if it will be called with a key that doesn't respect the requirement. But that same requirement doesn't seem to be applied inside the function.

I understand that this could be seen as a XY problem, but it got me really interested in whether there is a way to make this specific problem work correctly.


Solution

  • Inside the implementation of sample2(), the type TObj[F] extends () => void ? F : never is an unresolved conditional type. That is, it's a conditional type that depends on a currently-unspecified generic type parameter to be resolved. In such cases, the compiler generally doesn't know what to do with it and treats it as essentially opaque. (See microsoft/TypeScript#23132 for some discussion of this.) In particular it doesn't realize that TObj[Tobj[F] extends ()=>void ? F : never] will ultimately have to resolve to some subtype of ()=>void.

    In general I'd avoid conditional types entirely unless they are necessary. The compiler can more easily understand and infer from mapped types like Record<K, V>:

    function sample2<K extends PropertyKey, T extends Record<K, () => void>>(
      obj: T,
      prop: K
    ) {
      obj[prop]();
    }
    

    And that behaves similarly when you call it:

    const obj2 = {
      func() { console.log("func") },
      prop: 42
    };
    sample2(obj2, "func"); // okay, 
    //sample2(obj, "prop"); // error
    //      ~~~ <-- number is not assignable to ()=>void
    

    EDIT: to address the original sample(), I'd use this definition:

    function sample<
      PK extends PropertyKey,
      FK extends PropertyKey,
      T extends Record<PK, any> & Record<FK, (v: T[PK]) => void>
    >(
      comp: T,
      prop: PK,
      setProp: FK
    ) {
      comp[setProp](comp[prop]);
    }
    
    const obj = {
      func(z: number) { console.log("called with " + z) },
      prop: 42
    }
    

    which, I think, also behaves how you'd like:

    sample(obj, "prop", "func"); // called with 42
    sample(obj, "prop", "prop"); // error!
    //     ~~~ <-- number not assignable to (v: number)=>void
    sample(obj, "func", "func"); // error!
    //     ~~~ <-- (v: number)=>void not assignable to number
    

    Okay, hope that helps; good luck!

    Link to code