Search code examples
typescriptgenericstype-inference

Inferring a generic parameter from a function passed as argument


I have a function which accepts adapters and their options as an optional argument.

// One type of query adapter
type O1 = { opt: 1 }
const adapter1 = (key: string, options?: O1) => 1
// Second type of query adapter
type O2 = { opt: 2 }
const adapter2 = (key: string, options?: O2) => 2
// There can be hypothetically any kind of query adapters, but they adhere to this general format

As said in the comment, there can be hypothetically infinite amount of adapters (hence the adapter consuming function needs to accept a generic argument). But the adapters do always have their Parameters in the comon format of

[string, options?: unknown (generic)]

Here is the definition of the adapter consuming function:

// type definition helper for the adapter
type Fn = <O, R>(key: string, options?: O) => R

// 'consumer' function which accepts these adapters and their options as an optional argument
const query = <F extends Fn, O extends Parameters<F>[1]>(
  key: string,
  adapter: F,
  options?: O
): ReturnType<F> => adapter(key, options)

I would expect typescript to correctly infer the options based on the passed arguments, that's however not the case:

// These should pass (omitted optional config)
query('1', adapter1)
query('2', adapter2)

// These should pass as well (with config applied)
query('1config', adapter1, { opt: 1 })
query('2config', adapter2, { opt: 2 })

// These should throw error due to config type mismatch
query('1error', adapter1, { foo: 'bar' })
query('2error', adapter2, { foo: 'bar' })

However all of these throw a TS error due to mismatch on the passed adapter arguments. With the following error:

Argument of type '(key: string, options?: O1) => number' is not assignable to parameter of type 'Fn'.
  Types of parameters 'options' and 'options' are incompatible.
    Type 'O | undefined' is not assignable to type 'O1 | undefined'.
      Type 'O' is not assignable to type 'O1 | undefined'.

Now in the example this could be fixed by changing O to O extends O1 | O2 | undefined, but as mentioned in the question, there can be hypotehtically infinite variations in the adapter options, but I still need my consumer function to correctly infer the option object to provide type safety when the user types the query function and specifies the options object.

The real issue is bit more complex with callbacks, but this is the minimal reproducible example that does nicely describe the issue. Please don't comment on how it's nonsensical to not use (key, options) => adapter(key, options) for invocation directly, as it's outside the scope of the question.

Here's the TS Playground for you to experiment on


Solution

  • The main problem here is that your Fn type has the generic type parameters in the wrong scope. Your Fn means that the caller of an Fn can choose O and R and the implementation has to be able to handle any choice the caller makes. Neither adapter1 nor adapter2 behave that way. Instead you should write

    type Fn<O, R> = (key: string, options?: O) => R
    

    which means that the implementer chooses O and R and the caller of Fn<O, R> can only use those specific choices. See typescript difference between placement of generics arguments for more information about generic type parameter scope issues.

    So you can change Fn to Fn<any, any> in your query() (and remove the unnecessary O type parameter) and it will start working as expected:

    const query = <F extends Fn<any, any>>(
        key: string, adapter: F, options?: Parameters<F>[1]
    ): ReturnType<F> => adapter(key, options)
    
    query('1', adapter1) // okay
    query('2', adapter2) // okay
    query('1config', adapter1, { opt: 1 }) // okay
    query('2config', adapter2, { opt: 2 }) // okay
    query('1error', adapter1, {}) // error, opt is missing
    query('2error', adapter2, { foo: 'bar' }) // error, foo is unexpected
    

    That's the answer to the question as asked, but there is a secondary issue I want to bring up:


    You're using the Parameters and ReturnType utility types with a generic parameter. These utility types are implemented as conditional types, and the compiler is very bad about type checking generic conditional types. It won't notice, for example, that this is a problem:

    const badQuery = <F extends Fn<any, any>>(
        key: string, adapter: F, options?: Parameters<F>[1]
    ): ReturnType<F> => adapter(key, 389398) // no error!!
    

    For the example code here it's much better to just make query() generic in O and R directly, instead of trying to tease that informatin out of F:

    const query = <O, R>(
        key: string, adapter: Fn<O, R>, options?: O
    ): R => adapter(key, options); 
    

    This behaves the same from the caller's side, but now it will catch problems in the implementation as well:

    const badQuery = <O, R>(
        key: string, adapter: Fn<O, R>, options?: O
    ): R => adapter(key, 389398); // error as expected
    

    Playground link to code