2

E.g. I have a function to accept an array of configs. Each array element can be a subset of the config. I'll merge every array element into a single config, which should match the Config type.

I'm currently doing it this way:

type Config = {
    a: number,
    b: number,
    c?: number,
};

function fn(arr: Partial<Config>[]) {}

fn([{ a: 1, b: 1, c: 1 }]); // ok
fn([{ a: 1 }, { b: 1 }]); // ok
fn([{ a: 1 }]); // invalid config, but no TS error

The last function call is missing the required b property. I want to check the whole array against the Config type. I figured out this hack that seems to work:

// From https://stackoverflow.com/a/50375286
type UnionToIntersection<U> =
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never;

function fn<T extends Partial<Config>>(
  arr: T[] & (UnionToIntersection<T> extends Config ? any : never),
) {}

fn([{ a: 1, b: 1, c: 1 }]); // ok
fn([{ a: 1 }, { b: 1 }]); // ok
fn([{ a: 1 }]); // Type 'number' is not assignable to type 'never'

TS Playground

Is there a non-hacky way to do this? Ideally, the error message would be clearer.

Edit:

As jcalz pointed out in the comments, my solution doesn't work for fn([{ a: 1 }, { c: 3 }]). This is because:

const arr = [{ a: 1 }, { c: 3 }];
type T = (typeof arr)[number];
/* type T = {
    a: number;
    c?: undefined;
} | {
    c: number;
    a?: undefined;
} */
type T2 = {
    a: number;
    c?: undefined;
} & {
    c: number;
    a?: undefined;
}; // never

I thought (typeof arr)[number] would be { a: number } | { b: number }. This fixes it:

type OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T];

type OmitOptional<T> = T extends any ? Omit<T, OptionalKeys<T>> : never;

function fn<T extends Partial<Config>>(
    arr: T[] & (UnionToIntersection<OmitOptional<T>> extends Config ? any : Config[]),
) {}

fn([{ a: 1 }, { c: 1 }]); // Property 'b' is missing in type '{ a: number; }' but required in type 'Config'.
Leo Jiang
  • 24,497
  • 49
  • 154
  • 284
  • "The last function call is missing the required `c` property" do you mean `b`? – jcalz Dec 12 '21 at 03:09
  • Yes, b! Fixed it – Leo Jiang Dec 12 '21 at 03:11
  • Your solution doesn't catch `fn([{ a: 1 }, { c: 3 }])` as far as I can tell; The type of `[{a: 1},{c:3}]` is `Array<{a: number, c?: undefined} | {a?: undefined, c: number}>` which when you intersect the elements gives `never`. – jcalz Dec 12 '21 at 03:15
  • Is the input to `fn()` always an array literal of object literals, passed directly into `fn()`? Do you just want to accept `fn(x)` where `x` is of type `Array<{a: number} | {b: number}>`? What if any of the inputs ever themselves unions? Like, if I call `fn([Math.random() < 0.5 ? { a: 1 } : { b: 2 }])`, what do you expect to happen? I think there are probably a lot of caveats here no matter what one proposes. – jcalz Dec 12 '21 at 03:19
  • It's not always literals, but for now, there aren't unions passed in. Ideally, it'll accept the input if and only if both `a` and `b` are in any of the array elements, duplicates are fine. In the case of `fn([Math.random() < 0.5 ? { a: 1 } : { b: 2 }])`, it should require both `a` and `b` in other array elements. However, it doesn't matter if it's not perfect, any improvements over my current type would be great – Leo Jiang Dec 12 '21 at 03:26
  • There's no way for the compiler to tell the difference between `const x = [Math.random()<0.5 ? {a: 1} : {b: 2}]; fn(x)` and `const x = [{a: 1}, {b: 2}]; fn(x)` at the type level, so to some extent what you want is impossible. Do you consider [this](https://tsplay.dev/mLLPVm) an improvement or a "hack"? (I'm not sure your version is a "hack" either. You are trying to represent a type function very much like "the intersection of all the elements in the array should be `Config`", which is quite close to what you did. But "hack" is in the eye of the beholder, so ‍♂️) – jcalz Dec 12 '21 at 03:32
  • That's works great, thanks! I don't think union array elements are an issue, I can still have sanity checks in JS. If you make this an answer, I'll accept it – Leo Jiang Dec 12 '21 at 03:53
  • 2
    I will write up an answer but it might not be for a little while (it is close to my bedtime in my time zone). – jcalz Dec 12 '21 at 03:54
  • Added an edit which fixes my solution, your addition of `Config[]` for clearer error message is really clever! – Leo Jiang Dec 12 '21 at 04:56
  • Is this an internal function or is it a public function that might be called from Javascript? Because typically API that takes options objects tend to be public API. The answer to this changes everything. – Inigo Dec 12 '21 at 05:41
  • I'd probably try overloads and tuple types instead of arrays: `fn([T]); fn((T & U) extends Config ? [T, U] : never); fn((T & U & V) extends Config ? [T, U, V] : never); …` – Bergi Dec 12 '21 at 06:03

1 Answers1

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360