For best results, make type parameter inference as easy as possible for the compiler
When you call a generic function without manually specifying its type parameters, the compiler needs to infer these type parameters, often from values passed as parameters to the function. Let's say we have a function whose call signature is
// type F<T> = ...
declare function foo<T>(x: F<T>): void;
And we call it like this:
// declare const someValue: ...
foo(someValue);
Then the compiler's job is to determine T
based on the type of someValue
and the definition of F<T>
. The compiler has to try to invert F
, and deduce T
from F<T>
. The easier that is, the more likely it is that the inferred type parameter matches your expectations.
In your case, you have something like
function func<
T extends Param[],
X extends { [K in keyof T]: X[K] } = InferX<T>,
Y extends { [K in keyof T]: Y[K] } = InferY<T>,
>(
arg: [...{ [K in keyof T]: Param<X[K], Y[K]> }]
): void;
but the compiler is unable to infer T
, X
, and Y
from a value of type [...{ [K in keyof T]: Param<X[K], Y[K]> }]
. It's just too much type manipulation for the compiler to do. You will have better luck if you do something simpler like
function func<T extends Param[]>(arg: T): void;
Variadic tuple types in function parameter types give the compiler a hint to infer tuples instead of just arrays
This works, but the compiler tends to infer array types instead of tuple types:
declare function func<T extends Param[]>(arg: T): void;
func([{ x: "", y: 1 }, { x: 1, y: "" }])
// T inferred as Array<{x: string, y: number} | {x: number, y: string}>
Since you want T
to be inferred as a tuple type and not just an array type, you can use variadic tuple types to get this behavior, by changing arg: T
to arg: [...T]
or arg: readonly [...T]
:
declare function func<T extends Param[]>(arg: [...T]): void;
func([{ x: "", y: 1 }, { x: 1, y: "" }])
// T inferred as [{ x: string; y: number;}, { x: number; y: string;}]
Calculating the return type: no annotation yields unknown[]
Now there's the issue of what to do with the output type. It's not void
. Let's examine the implementation of the example code:
function func<T extends Param[]>(arg: [...T]) {
return arg.map(elem => 'x' in elem ? elem.x : elem.y)
}
If we don't annotate the return type, it will be determined by the compiler to be unknown[]
; the map()
method of arrays doesn't preserve tuple lengths, and it certainly can't follow the higher order logic that would produce different types for different indices. (See Mapping tuple-typed value to different tuple-typed value without casts for more information.)
So you'd get unknown[]
:
const p: Param<string, number> = (
[{ x: "", y: 1 }, { y: 1 }, { x: undefined, y: 1 }]
)[Math.floor(Math.random() * 3)]
const result = func([
{ x: "", y: 1 },
{ y: 1 },
{ x: undefined, y: 1 },
p
])
// const result: unknown[]
which is true but useless for you. Since the compiler can't infer a strong type for the return value of map()
, we will need to assert it as something that's a function of T
. But what function of T
?
Calculating the return type: a union of the x
and y
properties at each element
Well, for each numeric index I
, you looking at the element T[I]
and returning either its "x"
property of type T[I]["x"]
or its "y"
property of type T[I]["y"]
. So a first pass at this would be to return the union of these types:
// here E is some element of the T array
type XorY<E> = E extends Param ? E["x"] | E["y"] : never;
function func<T extends Param[]>(arg: [...T]) {
return arg.map(elem => 'x' in elem ? elem.x : elem.y) as
{ [I in keyof T]: XorY<T[I]> }
}
const result = func([
{ x: "", y: 1 },
{ y: 1 },
{ x: undefined, y: 1 },
p
])
// const result: [string | number, unknown, number | undefined, string | number | undefined]
That's better; we have a tuple of length four, and the string
and number
types are in there for the most part. But there are a few drawbacks.
One drawback is that the compiler doesn't realize that if x
exists you'll definitely get it; presumably for the first element of result
the type should be string
, not string | number
.
Another is that if x
is not known to exist, the type unknown
is coming out. This is actually technically correct; the compiler cannot know that the type it infers for {y: 1}
means that the x
property is definitely missing. The type {y: number}
does not mean "no properties but y
exist"; it just means "no known properties but y
exist". (That is, object types are not "exact" in the sense requested in microsoft/TypeScript#12936.) So the compiler decides T[I]['x']
is unknown
, which wrecks things. Let's do the technically-incorrect-but-convenient thing and say that a type like {y: 1}
means that x
is missing and therefore we want just the type of y
coming out.
Calculating the return type: x
if it exists, y
if it doesn't, and the union if we don't know
So let's redefine the XorY<E>
type from above so that it does the following analysis on the element type E
of the array:
- if
E
has a non-optional x
property of type X
, we should return X
.
- if
E
has no x
property but it has a y
property of type Y
, we should return Y
.
- otherwise, if
E
has an optional x
property of type X
and a y
property of type Y
, then we should return X | Y
.
That should cover all the cases (but of course there could be edge cases, so you need to test).
It's a little difficult to accurately check if the x
property is optional or not; see this answer for details. Here's one way to write XorY
:
type XorY<E> = E extends Param<any, infer Y> ? E['x'] extends infer X ?
'x' extends keyof E ? {} extends Pick<E, 'x'> ? X | Y : X : Y
: never : never
It calculates X
and Y
as a function of E
in a weird way; Y
is found with conditional type inference but for X
I am indexing into E
directly; that's because X
will not include undefined
if you use infer
, but optional properties are sometimes undefined
(give or take the --exactOptionalProperties
compiler flag). So E['x']
is more accurate than infer
when it comes to undefined
.
Anyway, armed with X
and Y
, we return X | Y
if x
is a known key ('x' extends keyof E
) and if it is optional ({} extends Pick<E, 'x'>
). If it is known but not optional, we return X
. And if it is not known, then we return Y
.
Okay let's try it:
const result = func([
{ x: "", y: 1 },
{ y: 1 },
{ x: undefined, y: 1 },
p
])
// const result: [string, number, undefined, string | number | undefined]
console.log(result) // ["", 1, undefined, something]
Perfect! That's about as specific as we could hope for. Again, there may be edge cases and you should test to make sure it works with your actual use cases. But I think this is as close to a solution as I can imagine for the example code as given.
Playground link to code