skip to Main Content
type MyMap = {
  a: () => void
  b: (s: string) => void
  c: (n: number) => void
}
type MyKey = keyof MyMap
function wrapper(fn: MyMap[MyKey]) {
  return async function(...arg:Parameters<MyMap[MyKey]>){
    await Promise.resolve();
    fn.apply(null, arg);
  }
}

It should work because I know I pass a fn to wrapper, and fn.apply the same arg. But Typescript throws an error:

The 'this' context of type '(() => void) | ((s: string) => void) | ((n: number) => void)' is not assignable to method's 'this' of type '(this: null) => void'.
  Type '(s: string) => void' is not assignable to type '(this: null) => void'.
    Target signature provides too few arguments. Expected 1 or more, but got 0.

Playground link

2

Answers


  1. I don’t know that it’s possible to solve this in the way that you want but hopefully @jcalz can swoop in and correct me.

    As to why your current implementation doesn’t work, keyof MyMap is actually the union of all possible keys in MyMap, i.e. 'a' | 'b' | 'c'. Inside your wrapper function you are essentially saying

    function wrapper(fn: MyMap['a' | 'b' | 'c']) {
      return async function(...arg:Parameters<MyMap['a' | 'b' | 'c']>){
        await Promise.resolve();
        fn.apply(null, arg);
      }
    }
    

    arg is therefore inferred to be [] | [s: string] | [n: number] as Parameters distributes across MyMap['a'], MyMap['b'] and MyMap['c']

    You can see why by the time you’re trying to apply arg to fn, TypeScript doesn’t know the type of fn and how it relates to the type of your ...arg so it complains


    Ideally what you want is to tell TypeScript that you are only accessing 1 function on the MyMap type so the downstream typing will work in the context of a single function

    You may be tempted to try something like this

    function wrapper<K extends keyof MyMap>(fn: MyMap[K]) {
      return async function(...arg:Parameters<MyMap[K]>){
        await Promise.resolve();
        fn.apply(null, arg);
      }
    }
    

    but you will encounter the same issue because we are still extending keyof MyMap which is the union of all keys.

    Even though in practice we would use it as follows

    const someAFn: MyMap['a'] = () => {} // valid
    wrapper<'a'>(someAFn)                // types match
    

    from inside the function TypeScript still has to protect for the use case where K is not just a union of a single type (e.g. 'a' | 'b', which is a perfectly valid keyof MyMap)

    As far as I know, there is currently no way to inform typescript to restrict a generic expecting a union type to a single member


    Instead of dealing with MyMap directly from within wrapper, you could instead extract your function before passing it into wrapper and just infer the arguments from there. This guarantees that you will only ever be passing a single function of a fixed signature into wrapper which TypeScript can easily deal with

    function wrapper<T extends (...args: any[]) => void>(fn: T) {
      return async function(...arg:Parameters<T>){
        await Promise.resolve();
        fn.apply(null, arg);
      }
    }
    
    const someAFn: MyMap['a'] = () => {}
    const someBFn: MyMap['b'] = (someString: string) => {}
    const someCFn: MyMap['c'] = (someNumber: number) => {}
    
    const aFn = wrapper(someAFn) // () => Promise<void>
    const bFn = wrapper(someBFn) // (s: string) => Promise<void>
    const cFn = wrapper(someCFn) // (n: string) => Promise<void>
    

    I imagine this goes against the essence of what you are trying to achieve with a "wrapper function", but it hopefully sheds some light on where the difficulties arise

    Playground

    Login or Signup to reply.
  2. TypeScript can’t follow the sort of correlation between the union types of MyMap[keyof MyMap] (a union of three function types) and the Parameters<MyMap[keyof MyMap]> (a union of three tuple types). Your intention is that when the former is (s: string) => void then the latter will be [string], but the compiler is worried about possible mismatch. The general issue about the lack of language support for correlated union types is described in microsoft/TypeScript#30581.

    The recommended approach to such cases is to refactor to a simple "base type" and then perform generic indexed accesses into that type, or into mapped types over that type. This is described in detail in microsoft/TypeScript#47109.

    For your example, that would look like this:

    interface MyMapArgs {
      a: [],
      b: [s: string],
      c: [n: number]
    }
    
    type MyMap = {
      [K in keyof MyMapArgs]: (...args: MyMapArgs[K]) => void
    }
    

    The base type is MyMapArgs, which directly represents the parameter tuples. And now MyMap is a mapped type over MyMapArgs. Your function then needs to be made generic in K extends keyof MyMap, and you can then talk about MyMapArgs[K] (a simple indexed access type) directly instead of Parameters<MyMap[K]> (using the Parameters<T> utility type which is implemented as a more complicated conditional type that the compiler can’t reason much about):

    function wrapper<K extends keyof MyMap>(fn: MyMap[K]) {
      return async function (...arg: MyMapArgs[K]) {
        await Promise.resolve();
        fn.apply(null, arg); // okay
      }
    }
    

    That compiles just fine.


    Unfortunately that you will immediately run into the fact that when you call the function, the compiler cannot narrow the type of K to the appropriate member (there is no value of type K sent to the function), and therefore the return type is inaccurate:

    const w = wrapper((s: string) => console.log(s.toUpperCase()));
    // const w: (...arg: [] | [s: string] | [n: number]) => Promise<void>
    
    await w(123); // oops, this is allowed
    // 💥 RUNTIME ERROR: s.toUppercase is not a function
    

    Probably the best way to deal with this is to explicitly make some use of "a", "b", and "c":

    function wrapper<K extends keyof MyMap>(k: K, fn: MyMap[K]) {
      return async function (...arg: MyMapArgs[K]) {
        await Promise.resolve();
        fn.apply(null, arg);
      }
    }
    

    Now when you call it the compiler narrows as expected:

    const w = wrapper("b", (s: string) => console.log(s.toUpperCase()));
    // const w: (s: string) => Promise<void>
    

    That argument is unneeded at runtime, but it really helps the compiler understand what you’re doing. If you want to avoid that then the compiler will not follow your logic and the best you can do is use type assertions or the like, since functions at runtime do not carry information about their expected argument types.

    function wrapper<F extends MyMap[keyof MyMap]>(fn: F) {
      return async function (...arg: Parameters<F>) {
        await Promise.resolve();
        (fn as any).apply(null, arg); // <-- need to assert here
      }
    }
    
    const w = wrapper((s: string) => console.log(s.toUpperCase()));
    // const w: (s: string) => Promise<void>
    

    Playground link to code

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search