I would like to create a type to check if all attributes of an interface
are given. The attributes can be set over multiple objects.
I know, that by using conditional recursive types a list of objects can be merged. But how is it possible to compare the result of merging with the given interface?
interface Box {
height: number,
width: number,
length: number,
weight: number
}
type Check<T, I> = ???
function createBox<T>(...args: Check<T, Box>):Box {
...
}
Typescript should yell that weight is missing, but is required in Box.
createBox({height: 11, width: 15}, {length: 24});
Arguments can overwrite attributes from previous arguments.
createBox({height: 11, width: 15}, {length: 24, width: 3});
Please share some of your thoughs how that works for better understanding.
Essentially you want to check Check<T extends readonly object[], U extends object>
to make sure that when you intersect all the elements of T
together, you get something assignable to U
. That's because when you Object.assign()
the elements of an array of type T
together you get a value of that intersection type, more or less (this is not strictly true if a property of one type get overwritten by a property of a different type, but I'm not going to worry about this here; without a true spread type operator as requested in microsoft/TypeScript#10727, intersection is a close enough approximation for our purposes).
If that check passes then Check<T, U>
should just be T
. If it fails, then Check<T, U>
should be something that looks very much like T
except where, say, the last element is modified to whatever would be necessary for the check to pass. This way you'll get an error that tells you about missing properties in your last argument.
So, that's the overall approach. Here are the details:
type TupleToIntersection<T extends readonly any[]> =
{ [I in keyof T]: (x: T[I]) => void } extends { [k: number]: (x: infer I) => void } ? I : never
type AugmentLastElement<T extends readonly any[], U> =
T extends readonly [...infer I, any] ? [...I, Partial<U> &
{ [K in keyof U as K extends keyof TupleToIntersection<I> ? never : K]: U[K] }] :
[U];
type Check<T extends readonly any[], U> =
T extends (TupleToIntersection<T> extends U ? unknown : never) ? T :
AugmentLastElement<T, U>;
The TupleToIntersection<T>
type just computes the intersection as described in Is there any way to get intersection type of generic rest parameters?. So if T
is [{a: string, b: number}, {c: boolean}]
, then TupleToIntersection<T>
is {a: string, b: number} & {c: boolean}
.
Then AugmentLastElement<T, U>
checks if T
has a last element. If so, it replaces that element with effectively Omit<U, keyof TupleToIntersection<I>>
where I
is the initial part of T
without the last element. So if T
is [{a: string, b: number}, {c: boolean}]
and U
is {a: string, b: number, c: boolean, d: Date}
, I
is [{a: string, b: number}]
(the last element of T
is gone) and keyof TupleToIntersection<I>
is "a" | "b"
, and Omit<U, keyof TupleToIntersection<I>
is effectively {c: boolean, D: Date}
. I'm not using the Omit<T, K>
utility type directly, but rather I'm using a key remapped type with as
to filter out the keys (mapping a key to never
suppresses it). And I'm also intersecting with Partial<U>
so that the error message doesn't complain about "excess" properties if your last element repeats properties from earlier. Oh and finally if T
is an empty array type, then AugmentLastElement<T, U>
is just [U]
, since that means you passed in no arguments, so the closest correct argument list would be a single argument of type U
.
And finally Check<T, U>
checks that TupleToIntersection<T> extends U
. If it does, Check<T, U>
evaluates to T
(so the check passes, since we are comparing T
to Check<T, U>
). If it does not, then it evaluates to AugmentLastElement<T, U>
, so you'll get a reasonable error about what you did wrong.
Let's test it out:
interface Box {
height: number,
width: number,
length: number,
weight: number
}
function createBox<T extends Partial<Box>[]>(...args: Check<T, Box>): Box {
return Object.assign({}, ...args);
}
createBox(); // error, Expected 1 arguments, but got 0.
createBox({ height: 11, width: 15 }, { length: 24 }); // error!
// Property 'weight' is missing --> ~~~~~~~~~~~~~~
createBox({ height: 11, width: 15 }, { length: 24, weight: 100 }); // okay
createBox({ height: 1 }, { weight: 2 }, { length: 3 }, { width: 4 }); // okay
createBox({ height: 11, width: 15 }, { length: 24, width: 3 }); // error!
// Property 'weight' is missing --> ~~~~~~~~~~~~~~~~~~~~~~~~
Looks good. It passes and fails where it's supposed to, and the error messages mention what needs to be done to fix it.
That's as close as I can get to the desired behavior.
Note that by making createBox
generic but conditional in the type T
of args
, it is easy for TypeScript to fail to infer T
properly. If T
cannot be inferred by the way Check<T, Box>
is written, it falls back to the constraint of Partial<Box>[]
, which would not be useful for us, as that loses track of which particular properties were actually passed.
Such inference is quite fragile. If you want to have IntelliSense prompt you for all the properties of Box
in each argument, you'd need to have each argument's type be of type, say, Partial<Box>
, as well as the actual passed-in type. But when we do that, it breaks the inference. I have to leave it alone here, unless you want to refactor your code away from a single function call. You might be able to write it like createBox({ height: 11, width: 15 }).and({ length: 24, weight: 100 }).build()
where build()
would fail unless you had all the properties, but this is out of scope for the question as asked. So again, I'm leaving it like this.