Assuming that callers of fn()
pass in an array they already had lying around instead of always passing in an array literal, the compiler might not know the specific types of each element in the array. That is, the passed-in array might not be of a tuple type:
For example:
const arr = [{ a: 1 }, { b: 2 }];
/* const arr: ({
a: number;
b?: undefined;
} | {
b: number;
a?: undefined;
})[] */
Here, the compiler infers that arr
is of type Array<{a: number; b?: undefined} | {b: number; a?: undefined}>
. And for all the compiler knows or cares, it could be an empty array, or an array of one value, or an array of a hundred values all of type, say, {a: number; b?: undefined}
:
if (Math.random() < 0.5) {
arr.pop(); // ♂️
}
So to some extent, what you want is not generally possible. Or at least, you will have to decide whether to allow some arrays that turn out not to have all the properties in Config
, or whether to disallow some arrays that turn out to have all the properties in Config
. False negatives and false positives are probably unavoidable.
That being said, I will suggest a "best effort" that has some chance of false negatives by assuming that a value of type Array<X | Y | Z>
(an array whose element type is a union) has at least one element each of type X
, Y
, and Z
. This is similar to assuming that the merged config will have the intersection type X & Y & Z
, which is what you were doing with UnionToIntersection
.
And a version of UnionToIntersection
could be made to work once you account for the often-helpful-but-not-here normalization of object literals that contribute to union types. Since [{a: 1}, {c: 2}]
produces types like {a: number, c?: undefined} | {c: number, a?: undefined}
, a straightforward intersection will give never
. You could programmatically massage the element type into {a: number} | {c: number}
and then intersect that to get {a: number; c: number}
. But instead I'll do something a little different:
type AllKeys<T> = T extends unknown ? keyof T : never;
function fn<T extends Partial<Config>>(
arr: T[] & (Pick<Config, AllKeys<T>> extends Config ? unknown : Config[]),
) { }
So AllKeys<T>
will produce a union of all the keys present in any union member of T
. While keyof ({a: string} | {b: number})
is never
, AllKeys<{a: string} | {b: number})
is "a" | "b"
. If we assume that the array has at least one element of each union member type, and we assume that each key is definitely present in at least one element, then we can conclude that the merged config will have all the keys in AllKeys<T>
. And since T extends Partial<Config>
is true, we can conclude that the merged config will be of type Pick<Config, AllKeys<T>>
(using the Pick<T, K>
utility type).
The type Pick<Config, AllKeys<T>>
should be quite similar to UnionToIntersection<T>
, except that the latter is too sensitive to the particular property value types of T
, while the former only really cares about keys, trusting that the values will be okay given that T extends Partial<Config>
.
To make the error message "nice" (or at least "not terrible"), we are intersecting T[]
(the inferred type of arr
), with the conditional type Pick<Config, AllKeys<T>> extends Config ? unknown : Config[]
. If T
has all the required keys of Config
, then this will result in T[] & unknown
which is T[]
(intersections with the unknown
top type absorb the unknown
). If T
is missing any required key, then this will result in T[] & Config[]
, meaning that suddenly the compiler will expect that each element of arr
should be a full Config
instead of just a Partial<Config>
. This will mean that probably every array element will have an error associated with it, which is overkill, but at least the message has a chance of complaining that you are missing some keys instead of not being never
.
Let's try it:
fn([{ a: 1, b: 1, c: 1 }]); // okay
fn([{ a: 1 }, { b: 1 }]); // okay
fn([{ a: 1 }]); // error! Property 'b' is missing
fn([{ a: 1 }, { c: 3 }]) // error! 'b' is missing
Looks good! The compiler accepts the first two calls, and rightly complains about missing b
in the latter two cases.
As pointed out earlier, though, there are false negatives in cases where the assumptions of having at least one element containing each key from the element type:
const emptyConfigs: Config[] = [];
fn(emptyConfigs); // no error, but problem at runtime
And if you throw away information the compiler needs to verify the keys of arr
, then there can also be false positives:
const okay = [{ a: 1, b: 1 }];
const falsePositive: Array<{ a: number }> = okay;
fn(falsePositive); // error, but fine at runtime
But hopefully these are acceptable to you.
Playground link to code