Issue
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
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.