Search code examples
typescripttypescript-generics

Generic function parameter with unknown args


Is there a reason that specifying a generic parameter as a function with ...args: unknown[] doesn't play nicely?

// Using TS 4.2.3
const fn = (foo: boolean) => 'hello'

// Ideally, one generic function arg
type Data1<M extends (...args: unknown[]) => unknown> = {
  fn: M
}
const data1: Data1<typeof fn> = {
                   ^^^^^^^^^
  fn,
}
Type '(foo: boolean) => string' does not satisfy the constraint '(...args: unknown[]) => unknown'.
  Types of parameters 'foo' and 'args' are incompatible.
    Type 'unknown' is not assignable to type 'boolean'.

This TypeScript Playground example also demonstrates splitting the function into two generic parameters, which works but isn't nice from an API point of view. I'm hoping to better understand why one works, but the other doesn't.


Solution

  • It happens due to variance.

    declare function fn(foo: boolean): string
    
    type Data1<M extends (...args: unknown[]) => unknown> = {
      fn: M
    }
    
    const data1: Data1<typeof fn> = {
                          ^
    // Type '(foo: boolean) => string' does not satisfy the constraint '(...args: unknown[]) => unknown'.
    //  Types of parameters 'foo' and 'args' are incompatible.
    //    Type 'unknown' is not assignable to type 'boolean'.
      fn,
    }
    

    Your data type Data1 accepting a function type is covariant in that functions's result type and contravariant in functions's argument type. Imagine it (Data1) as a function (what it really is on type-level) accepting an argument (foo: boolean) => string. This function has to check whether this argument is 'assignable' to parameter's type (...args: unknown[]) => unknown. To check this checker must ensure two things:

    • function's return type string must be assignable to unknown. That is totally possible. All types are assignable to unknown.

    • parameter's argument type unknown[] must be assignable to function's argument type [boolean]. That's where the checker raises the error. Because unknown is assignable only to unknown and any.

    Why does it have to happen in the opposite direction when it comes to the arguments? Because the function passed as an argument is a consumer of data. It must handle a wider range of types than the function expected as a parameter. Simple example:

    function lowerCase(s: string): string {
      return s.toLowerCase()
    }
    
    function callCb(cb: (sn: string | number) => string | number): number {
      const stringOrNumber = cb(Math.random() ? 'string' : 10)
    
      return typeof stringOrNumber === 'string' 
        ? stringOrNumber.length 
        : stringOrNumber  ​
    } 
    
    callCb(lowerCase)
    

    TS playground with strictFunctionTypes turned off to illustrate unsoundness.

    While cb can return any subset of callCb's expected parameter return type (string, number, string | number) cb must be able to handle every type callCb can provide to it.


    type Data2<Args extends unknown [], Return extends unknown> = {
     ​fn: (...args: Args) => Return
    }
    
    const data2: Data2<Parameters<typeof fn>, ReturnType<typeof fn>> = {
     ​fn,
    }
    

    As for your Data2 type. You're providing it with exact fn's argument and result types both in covariant/positive position. First it checks whether the provided types are satisfying constraints:

    ​* first type argument [boolean] is assignable to unknown[] ​* second type argument string is assignable to unknown

    Then it checks whether the object you're passing on the right hand side of the assignment with type { fn: (foo: boolean) => string } is assignable to Data2's result type { fn: (...[boolean]) => string }. That it clearly is.


    You can turn off this behavior by disabling strictFunctionTypes option in tsconfig.json. Then typescript treats function arguments as bivariant. Though I'd strongly advise against that. ​