I want to write a function that modifies a method on a function prototype, like this:
function inject<
T,
O = {
[K in keyof T as T[K] extends (...args: any) => any ? K : never]: T[K];
},
K extends keyof O = keyof O,
F extends (...args: any) => any = O[K] // error
>(o: { prototype: T }, func_name: K, func: (ret: ReturnType<F>) => void) {}
But typescript reported an error saying type "O[K]" does not satisfy the constraint "(...args: any) => any".
How do I fix this, or should I write my code differently?
I don't know if this is the best or easiest way to do it, but I suppressed the error by changing the O generic.
function inject<
T,
O extends Record<any, (...args: any) => any> = {
[K in keyof T as T[K] extends (...args: any) => any ? K : never]: T[K] extends (...args: any) => any ? T[K] : never;
},
K extends keyof O = keyof O,
F extends (...args: any) => any = O[K]
>(o: { prototype: T }, func_name: K, func: (ret: ReturnType<F>) => void) {}
Part of the problem is that O could be an arbitrary value, so typescript has no reason to believe that O[K] will be a function. To fix this, O should extend an object with function values (just like K extends keyof O and is assigned to keyof O). The T[K] extends (...args: any) => any ? T[K] : never is necessary because I think typescript isn't smart enough to infer that the value has to be a function due to the never guard in the value. It needs to know that it's a function so it can fit the Record<any, (...args: any) => any> signature.
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
Related
I want to write a call function:
function call<F extends (...arg: any) => any, P extends Parameters<F>>(
fn?: F,
...arg: P
): ReturnType<F> | undefined {
if (fn) return fn(...arg) // Type 'P' must have a '[Symbol.iterator]()' method that returns an iterator
}
const fn = (a: string) => {}
// automatically infer `fn` arguments type
call(fn, )
what should I do?
This is a known bug in TypeScript; see microsoft/TypeScript#36874. It looks like the problem is triggered by F extends (...arg: any) => any instead of F extends (...arg: any[]) => any. That is, your arg rest parameter is of the anything-at-all any type instead of the array-of-anything any[] type. The obvious workaround is therefore to change any to any[], which shouldn't be a problem because function rest parameters are essentially always arraylike:
function call<F extends (...arg: any[]) => any, P extends Parameters<F>>(
fn?: F, ...arg: P): ReturnType<F> | undefined {
return fn?.(...arg) // okay (I used optional chaining here to deal with missing fn)
}
Looks good!
That's the answer to the question as asked, although it is useful to note that generally speaking, the preferred way to do this is to make call() generic in P (the rest parameter type) and R (the return type), and not use the Parameters<T> and the ReturnType<T> utility types:
function call<P extends any[], R>(fn?: (...arg: P) => R, ...arg: P): R | undefined {
return fn?.(...arg); // okay
}
They are similar, but your approach ends up being unable to properly represent what happens when fn is itself generic:
const pair = <T, U>(left: T, right: U): [T, U] => [left, right];
const ret = call(pair, "abc", 123); // old version of call
// const ret: [any, any] | undefined ☹
whereas the approach I recommend can take advantage of the support for higher order type inference from generic functions:
const ret = call(pair, "abc", 123); // new version of call
// const ret: [string, number] | undefined 👍
If you can rewrite generics using conditional types like Parameters<T> and ReturnType<T>, it will sometimes improve the compiler's ability to produce desirable results.
Playground link to code
This is a tricky one, but the solution is not.
You have to ensure the typescript that args is iterable. You can do that like manually saying to him - Hey this will be an array, believe me
Working playground
function call<F extends (...arg: any) => any, P extends Parameters<F>>(
fn?: F,
...args: P
): ReturnType<F> | undefined {
if (fn){
// here is the ugly hint to typescript
return fn(...(args as any[]))
}
return undefined;
}
Have you considered using apply as a workaround?
function call<F extends (...arg: any) => any, P extends Parameters<F>>(
fn?: F,
...arg: P,
): ReturnType<F> | undefined {
if (fn) return fn.apply(undefined, arg);
return undefined;
}
Playground
In Typescript, how can I constrain the type a field on a generic type to a particular type? In other words, I want to write a function that accepts an object obj and a key key of that object, but raises a Typescript error if obj[key] is not of type X. For instance, if X is string:
function foo<T extends {}, K extends keyof T ???>(obj: T, key: K) {
console.log(obj[key])
}
>> foo({ fox: 'Mulder' }, 'fox')
Mulder
>> foo({ fox: 22 }, 'fox')
<some error here>
Guerric's answer demonstrates how type parameters are usually constrained: 1) define T constrained to be an object, then 2) define K constrained to be a key of T.
For the OP's case, however, I would reverse this logic: 1) define Key constrained to be a generic property key (a.k.a string | number | symbol), then 2) define Obj constrained to be an object containing property Key:
function foo<Key extends PropertyKey, Obj extends Record<Key, string>>(obj: Obj, key: Key) {
console.log(obj[key]);
}
foo({ fox: 'Mulder' }, 'fox'); // works fine
foo({ fox: 22 }, 'fox'); // Error (number is not assignable to string)
Try it.
This way, you get readable errors (in this context, "number is not assignable to string" is much less confusing than "string is not assignable to never"), and also the implementation of the function (the console.log(obj[key]) bit) does not require any typecasting since obj is properly typed to have a Key key.
You could do like this:
function foo<T extends {}, K extends keyof T>(obj: T, key: T[K] extends string ? T[K] : never) {
console.log((obj as any)[key]);
}
foo({ fox: 'Mulder' }, 'fox'); // OK
foo({ fox: 22 }, 'fox'); // Argument of type 'string' is not assignable to parameter of type 'never'
It implies to cast obj as any in the implementation because never can't be used as an index type, even though nothing is assignable to never so a function call with an actual key parameter that would make key type resolve to never will never compile (by definition), but TypeScript is not clever enough to infer that.
TypeScript playground
I'm importing types from lowdb via #types/lowdb, and when using their mixins() method to configure mixins on a store, it complains that the argument I'm passing doesn't type-match:
Argument of type 'Map<string, Function>' is not assignable to parameter of type 'Dictionary<(...args: any[]) => any>'.
Index signature is missing in type 'Map<string, Function>'.ts(2345)
I assumed that the type accepted by the mixins method was essentially a map of functions indexed with a string. So figured that Map<string, function> would be an acceptable thing to pass it. The context:
async setupStore ({storeName, storeMixins} : {storeName: string, storeMixins: Map<string, Function>}) {
const store: LowdbSync<any> = await lowdb(new StoreMemoryAdapter(storeName))
store._.mixin(storeMixins)
}
I guess my confusion here is a lack of understanding of what Dictionary<(...args: any[]) => any> expects in real terms. I'm unable to use the type declaration myself as it is not available to me in userland. But perhaps Map<string, Function> is not the correct equivalent?
The error message is most important here:
Index signature is missing in type 'Map<string, Function>'.ts(2345)
More information about index signatures you can find in the docs
Let's take a look on Map type definition:
interface Map<K, V> {
clear(): void;
delete(key: K): boolean;
forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void;
get(key: K): V | undefined;
has(key: K): boolean;
set(key: K, value: V): this;
readonly size: number;
}
As you might have noticed there is no index signature {[prop: string]:any}
This means that it is impossible to do smtg like this: Map<string, string>[string].
COnsider this example:
type Fn = (...args: any[]) => any
type A = Map<string, Fn>[string] // error
type B = Record<string, Fn>[string] // ok -> Fn
interface C {
[prop: string]: Fn
}
type Cc = C[string] // ok -> Fn
Btw, there is a small difference between types an interfaces in context of indexing.
Please see this answer.
I'm trying to create a React higher-order component for a specific use-case, the problem is boiling down to the following:
function sample<TObj, P extends keyof TObj, F extends keyof TObj>(
obj: TObj,
prop: P,
setProp: TObj[F] extends (value: TObj[P]) => void ? F : never
) {
obj[setProp](obj[prop]);
}
I want to be able to pass an object, a string which should be a key of that object, and another key of that object but which is required to be a function.
This can be simplified further as such:
function sample2<TObj, F extends keyof TObj>(
obj: TObj,
setProp: TObj[F] extends () => void ? F : never
) {
obj[setProp]();
}
It seems to me that because I use the conditional type, it can be guaranteed that obj[setProp] will be a function but I get the error:
This expression is not callable.
Type 'unknown' has no call signatures.ts(2349)
As can be seen below, the function will error if it will be called with a key that doesn't respect the requirement. But that same requirement doesn't seem to be applied inside the function.
I understand that this could be seen as a XY problem, but it got me really interested in whether there is a way to make this specific problem work correctly.
Inside the implementation of sample2(), the type TObj[F] extends () => void ? F : never is an unresolved conditional type. That is, it's a conditional type that depends on a currently-unspecified generic type parameter to be resolved. In such cases, the compiler generally doesn't know what to do with it and treats it as essentially opaque. (See microsoft/TypeScript#23132 for some discussion of this.) In particular it doesn't realize that TObj[Tobj[F] extends ()=>void ? F : never] will ultimately have to resolve to some subtype of ()=>void.
In general I'd avoid conditional types entirely unless they are necessary. The compiler can more easily understand and infer from mapped types like Record<K, V>:
function sample2<K extends PropertyKey, T extends Record<K, () => void>>(
obj: T,
prop: K
) {
obj[prop]();
}
And that behaves similarly when you call it:
const obj2 = {
func() { console.log("func") },
prop: 42
};
sample2(obj2, "func"); // okay,
//sample2(obj, "prop"); // error
// ~~~ <-- number is not assignable to ()=>void
EDIT: to address the original sample(), I'd use this definition:
function sample<
PK extends PropertyKey,
FK extends PropertyKey,
T extends Record<PK, any> & Record<FK, (v: T[PK]) => void>
>(
comp: T,
prop: PK,
setProp: FK
) {
comp[setProp](comp[prop]);
}
const obj = {
func(z: number) { console.log("called with " + z) },
prop: 42
}
which, I think, also behaves how you'd like:
sample(obj, "prop", "func"); // called with 42
sample(obj, "prop", "prop"); // error!
// ~~~ <-- number not assignable to (v: number)=>void
sample(obj, "func", "func"); // error!
// ~~~ <-- (v: number)=>void not assignable to number
Okay, hope that helps; good luck!
Link to code
I don't know how to declare second parameter of function boom to have proper type checking, I mean if somebody is sending to function boom first parameter as "foo1" then the second parameter should be only possible as: (number) => void. If "foo2" then (string) => void.
interface MyFoo {
foo1: (number) => void;
foo2: (string) => void;
}
class Bar<Foo> {
public boom<T extends Foo, K extends keyof MyFoo>(first: K,
...args: Parameters<T[K] /* here I don't know how to declare this parameter */){
}
}
new Bar<MyFoo>().boom("foo1", /* callback with signature: (number) => void */)
First of all, note that parameter names in function types are required, so this definition must be altered:
interface MyFoo {
foo1: (x: number) => void;
foo2: (x: string) => void;
}
Now, in Bar<Foo> we only need one generic parameter K on the boom() method, since the type Foo will be specified for us. I don't fully understand your use case, since it looks like maybe you want boom() to take a variable number of parameters, but this doesn't match the comment about how you need a callback function. So I assume that boom() takes two parameters: a key name and a callback function:
class Bar<Foo> {
public boom<K extends keyof Foo>(first: K, second: Foo[K]) { }
}
And then here's how you use it:
// okay, the callback is (x: number) => void
new Bar<MyFoo>().boom("foo1", (x: number) => console.log(x.toFixed(2)));
// error, the callback is (x: number) => void but should be (x: string) => void
new Bar<MyFoo>().boom("foo2", (x: number) => console.log(x.toFixed(2)));
By the way, this doesn't stop you from using an interface with non-function properties:
interface MyFoo { age: number }
new Bar<MyFoo>().boom("age", 40); // okay
If you require that boom() only work on keys corresponding to function-like properties, you will need some fancier conditional types:
type KeysMatching<T, V> =
Extract<keyof T, { [K in keyof T]: T[K] extends V ? K : never }[keyof T]>;
class Bar<Foo> {
public boom<K extends KeysMatching<Foo, Function>>(first: K, second: Foo[K]) { }
}
// okay, the callback is (x: number) => void
new Bar<MyFoo>().boom("foo1", (x: number) => console.log(x.toFixed(2)));
// error, the callback is (x: number) => void but should be (x: string) => void
new Bar<MyFoo>().boom("foo2", (x: number) => console.log(x.toFixed(2)));
interface MyFoo { age: number }
// error, "age" is not accepted anymore
new Bar<MyFoo>().boom("age", 40); // error
Hope that helps you. Good luck!