nicusor
nicusor

Reputation: 348

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.

Upvotes: 0

Views: 653

Answers (1)

jcalz
jcalz

Reputation: 328493

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

Upvotes: 1

Related Questions