Your main problem is that your O
type parameter has a default type argument but is not constrained to that type, and in any case, O
does not appear anywhere in the types of your function parameters, so there is no inference site for it.
Ideally you want as few type parameters as possible and you want them to appear in your function parameters as directly as possible. If you have a type parameter X
then it is most easily inferred from a parameter of type X
(e.g., <X,>(x: X) => void
); it can also be inferred from a parameter with a property of type X
(e.g., <X,>(v: {v: X}) => void
, or from a homomorphic mapped type on X
(e.g., <X,>(v: {[K in keyof X]: ...X[K]...}) => void
, see What does "homomorphic mapped type" mean? for more info). And more complicated things can also sometimes be used in inference, but the general rule is you want it to appear as directly as possible.
So I'd probably write inject()
's call signature as follows:
function inject<
K extends PropertyKey,
T extends Record<K, (...args: any) => any>
>(
o: { prototype: T },
func_name: K,
func: (ret: ReturnType<T[K]>) => void
) { }
Here K
is the type of func_name
, and can be inferred to be the string literal type of the func_name
argument because it is constrained to PropertyKey
which is string | number | symbol
. And T
is the type of the prototype
property of o
, where T
is constrained to have a function property at key K
(using the Record<K, V>
utility type). So T
will be inferred from the prototype
property of the o
argument, and there will be an error if o.prototype[func_name]
isn't of a function type.
Finally, the type of func
is a callback whose argument is ReturnType<T[K]>
using the ReturnType<T>
utility type. This will not be used to infer T
or K
(it's too complicated)... but that's okay because T
and K
should already be inferred from other arguments. Instead, the compiler will use ReturnType<T[K]>
with the already-inferred T
and K
to contextually type the callback argument. That is, inference is going the other way here. Let's test it out:
class Foo {
bar() {
return 123;
}
baz() {
return "xyz"
}
qux = 10
}
inject(Foo, "bar", x => x / 2);
inject(Foo, "bar", x => x.toUpperCase()); // error!
// Property 'toUpperCase' does not exist on type 'number'
inject(Foo, "baz", x => x.toUpperCase());
inject(Foo, "qux", x => x) // error!
// Type 'number' is not assignable to type '(...args: any) => any'
Looks good. The compiler is happy about the first call, and it understands that x
is of type number
because Foo.prototype.bar
is a number
-returning function. The second call it complains because number
doesn't have a toUpperCase
. Then the third call is okay because new Foo().baz()
returns a string
. The fourth call fails because new Foo().qux
isn't a function at all; it's a number
(plus it won't be on the prototype at all, but TypeScript models prototypes as identical to class instances, for better or worse, so that's not something the compiler could catch).
Playground link to code