Trying to create a simple util that would either:
- return in the given array as-is
- or transformed based on an optional param given. Here is the code:
type MapperFn<T, U> = (val: T) => U;
interface mapperOpts<T,U> {
cb?: MapperFn<T,U>
}
interface mapper {
map<T, U, Z extends mapperOpts<T,U>>(arr: Array<T>, opts: Z): Z extends { cb: MapperFn<T,U> } ? U[]: T[];
}
const obj: mapper = {
map: (arr, { cb }) => {
if (!cb) return arr;
return arr.map(cb);
}
}
const arr: number[] =[1,2,3];
const result = obj.map(arr, {cb: (element) => element.toString() }); // should be typed as `string[]`
const result2 = obj.map(arr, { cb: (element) => element 1 }); // should be typed as `number[]`
const result3 = obj.map(arr, {}); // should be types as `number[]`
However, I am getting the error:
Type '<T, U, Z extends mapperOpts<T, U>>(arr: T[], { cb }: Z) => T[] | U[]' is not assignable to type '<T, U, Z extends mapperOpts<T, U>>(arr: T[], opts: Z) => Z extends { cb: MapperFn<T, U>; } ? U[] : T[]'.
Type 'T[] | U[]' is not assignable to type 'Z extends { cb: MapperFn<T, U>; } ? U[] : T[]'.
Type 'T[]' is not assignable to type 'Z extends { cb: MapperFn<T, U>; } ? U[] : T[]'.
Note that result
and result2
are marked as unknown[]
which probably means argument type inference from callback function is not working correctly.
What am I missing?
CodePudding user response:
The specifics of how generic type argument inference works doesn't seem to be particularly well documented, aside from the now obsolete TypeScript Language Specification.
But in general, when the compiler sees a call to a function with a call signature of (say) func<T, U, V>(x: F<T, U>, y: G<T, V>): H<U, V>;
of the form c = func(a, b)
, it needs to try to infer the type parameters T
, U
, and V
from the types of the values a
, b
, and c
. To infer U
, the compiler would need to examine the types of a
and c
, since the parameter x
and the return type both depend on U
. So x
and the return type are potential inference sites for T
. On the other hand, it would be hopeless to try to use b
to infer anything about U
, because the type of the y
parameter does not refer to U
at all. That is, y
is not an inference site for U
.
Not all inference sites are treated equally, and some are harder to infer than others. Return types are usually poor inference sites, since often the compiler does not know the expected return type... if you wrote const c = func(a, b);
you are asking the compiler to infer the type of c
, so the return type of func()
is not known. You'd only be able to use the return type in a case where, for example, c
is already of a known type like const c: SomeType = func(a, b);
.
And the more complex the type function involving the type parameter, the less useful of an inference site it can be. For something like f<T>(x: T): void
, a call to f(a)
will easily infer T
to be the type of a
. But for something like g<T>(x: T[keyof T]): void
it will be nearly impossible to infer T
from g(a)
. In the former case, you are inferring T
from a value of the same type. Easy. In the latter case, inferring T
from a value of the union of the properties of that type. Not obvious how you'd even begin. Other type functions tend to fall somewhere in between those extremes. If you run into problems, your best bet is to simplify the type functions in the inference sites.
Finally, some places are not used as inference sites at all. A function signature like h<T, U extends T>(u: U): void
does not have an inference site for T
. Generic constraints are not consulted when inferring type parameters. Perhaps you might want the compiler to infer U
from what's passed to h()
, and then infer T
from U
, but that just isn't what happens. T
will surely fail to be inferred and will fall back to something like unknown
.
For issues where this comes up, you can look at microsoft/TypeScript#38183, microsoft/TypeScript#31529, and probably many others (I would search for "inference site").
So, all of that being said, my suggestion for your map()
method is this:
interface Mapper {
map<T, Z extends MapperOpts<T, any>>(arr: Array<T>, opts: Z):
Z extends { cb: MapperFn<T, infer U> } ? U[] : T[];
}
There was no reasonable inference site for U
in the previous version. Instead, we will just infer T
and Z
from arr
and opts
. This will most likely succeed. From that, we can use Z
to extract U
via explicit conditional type inference.
Let's see how it works:
const result = obj.map(arr, { cb: (element) => element.toString() }); // string[]
const result2 = obj.map(arr, { cb: (element) => element 1 }); // number[]
const result3 = obj.map(arr, {}); // number[]
Looks good!