You can enforce this using generic types on makeAggregateObject
. The issue of generators is not really a problem, this will enforce all common properties to have compatible types.
The way we will do it is to use a type parameter T
to capture in a tuple type all the argument types. We then say that the parameter must be T
(which is how we get the argument types into T
) but it also must be a type where each element is an intersection of all the types in the tuple. Basically if typescript infers from the argument that T = [A, B]
, then the argument must also be Array<Partial<A & B>>
. This will ensure that if a a property exists in both A
and B
, it must be an intersection of both property types. So if for example die
is (n:number) => boolean
in one object, but (s: string) => boolean
in the other, you will get an error on both objects that the property is not compatible with ((n:number) => boolean) & ((s: string) => boolean)
.
type UnionToIntersection<U> =
(U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never
function makeAggregateObject<T extends [any] | any[]>(objects: T & Partial<UnionToIntersection<T[number]>>[]) :UnionToIntersection<T[number]> {
return null!
}
const fish = {
swim: async function*(distance: number) { return distance > 10; }, // (number) => boolean
die: async function*() { } // () => void
};
const cat = {
lives: 9,
die: async function*() { /* this.lives -= 1; */ } // () => void
};
const human = {
walk: async function*(steps: number, hop: boolean) { }, // (number, boolean) => void
swim: async function*(distance: number) { return false; }, // (number) => boolean
// die: (n : number) =>true // uncomment this and you get an error
};
const aggregate = makeAggregateObject([
human,
cat,
fish
]);
// const aggregate: {
// swim: (distance: number) => AsyncGenerator<never, boolean, unknown>;
// die: () => AsyncGenerator<never, void, unknown>;
// } & {
// lives: number;
// die: () => AsyncGenerator<never, void, unknown>;
// } & {
// walk: (steps: number, hop: boolean) => AsyncGenerator<never, void, unknown>;
// swim: (distance: number) => AsyncGenerator<never, boolean, unknown>;
// }
aggregate.swim(0) // ok
aggregate.die() // also ok
console.log(aggregate);
Playground Link
Note: You can also express the signature of makeAggregateObject
as:
function makeAggregateObject<T>(objects: T[] & Partial<UnionToIntersection<T>>[]) :UnionToIntersection<T> {
return null!
}
The signature above is functionally equivalent functionally, but produces differently worded error messages. See which one you think is more readable. Neither are great. We can think of a more complicated type that introduces additional information into the type, but that complicates the types and is a bit abusive to the type system. If you are interested let me know and I can provide such a version.
Plug: UnionFromIntersection
is taken from here where you can also read the explanation of how it works make sure to upvote that great answer.
Edit
We can also extract the return type from the AsyncGenrator
return type and use it to create a Promise
type, as the new requirements of the question outline:
type MakeAggregateObject<T> = {
[P in keyof T]: T[P] extends (...p: infer P) => AsyncGenerator<never, infer R, any> ? (...p: P)=> Promise<R> : never
}
function makeAggregateObject<T extends [any] | any[]>(objects: T & Partial<UnionToIntersection<T[number]>>[]) :MakeAggregateObject<UnionToIntersection<T[number]>> {
return null!
}
Playground Link