Your mergeClasses
is limited because you have used the generic to describe each individual class. We can make it accept any number of classes by using a single generic C
to describe a tuple type of constructors.
const mergeClasses = <C extends Constructable[]>(...classes: C) =>
In order to get the types for the args arrays, we need to apply a mapped type to the tuple.
In order to get the type for the combined instance, we use another mapped type to get a tuple of all the instances. Indexing by [number]
on that gives us a union of all the instance types. We can apply @jcalz's famous UnionToIntersection
utility type to get an intersection of the instance types.
type Constructable = new (...args: any[]) => any;
// mapped type to apply ConstructorParameters to a tuple
type ToArgs<T> = {
[K in keyof T]: T[K] extends new (...args: infer A) => any ? A : never;
}
// mapped type to apply InstanceType to a tuple
type ToInstance<T> = {
[K in keyof T]: T[K] extends new (...args: any[]) => infer I ? I : never;
}
// utility type to convert a union to an instersection
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
// the type of the instance that the merged class creates
type CombinedInstance<C extends Constructable[]> = UnionToIntersection<ToInstance<C>[number]>
const mergeClasses = <C extends Constructable[]>(...classes: C) =>
(...argsArrays: ToArgs<C>): CombinedInstance<C> => {
const obj = {} as CombinedInstance<C>
classes.forEach((someClass, i) => {
const newObj = new someClass(argsArrays[i]);
for (const p in newObj) {
// p has type `string` so we need to assert
obj[p as keyof CombinedInstance<C>] = newObj[p];
}
});
return obj;
};
const mergeE = mergeClasses(B, C, D); // type: (argsArrays_0: [], argsArrays_1: [], argsArrays_2: []) => B & C & D
const e = mergeE([], [], []); // type: B & C & D
console.log(e); // logs properties of B, C, and D
Typescript Playground Link
This is accepting the args in the same format that you had before of ([], [], [])
. I don't love this format as typing out a bunch of empty arrays feels sloppy. It would be hard to flatten this to a single ...args
while still making sure that we pass the correct args to the correct constructors if every constructor can take a varying amount of arguments.
You could definitely make this cleaner if all of your classes have no arguments.
const mergeClasses = <C extends (new () => any)[]>(...classes: C) =>
(): CombinedInstance<C> => {
const obj = {} as CombinedInstance<C>
classes.forEach(someClass => {
const newObj = new someClass();
for (const p in newObj) {
// p has type `string` so we need to assert
obj[p as keyof CombinedInstance<C>] = newObj[p];
}
});
return obj;
};
const mergeE = mergeClasses(B, C, D); // type: () => B & C & D
const e = mergeE(); // type: B & C & D
Typescript Playground Link
You could also require that they all have a single argument which is a props object and have your merged class accept an object with all of the properties (assuming that none have conflicting property types).
// now takes only 1 argument
type Constructable = new (props: any) => any;
// source: https://stackoverflow.com/a/50375286/10431574
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
// mapped type to apply InstanceType to a tuple
type ToInstance<T> = {
[K in keyof T]: T[K] extends new (...args: any[]) => infer I ? I : never;
}
// now extracts only 1 argument
type ToArgs<T> = {
[K in keyof T]: T[K] extends new (props: infer A) => any ? A : never;
}
// the type of the instance that the merged class creates
type CombinedInstance<C extends Constructable[]> = UnionToIntersection<ToInstance<C>[number]>
// need to combine all args to one like with the instances
type CombinedArgs<C extends Constructable[]> = UnionToIntersection<ToArgs<C>[number]>
const mergeClasses = <C extends Constructable[]>(...classes: C) =>
(props: CombinedArgs<C>): CombinedInstance<C> => {
const obj = {} as CombinedInstance<C>
classes.forEach((someClass, i) => {
// pass the whole props object to each constructor
const newObj = new someClass(props);
for (const p in newObj) {
// p has type `string` so we need to assert
obj[p as keyof CombinedInstance<C>] = newObj[p];
}
});
return obj;
};
class B {
b: string;
constructor({ b }: { b: string }) {
this.b = b;
}
}
class C {
c: string;
constructor({ c }: { c: string }) {
this.c = c;
}
}
class D {
d: string;
constructor({ d }: { d: string }) {
this.d = d;
}
}
const mergeE = mergeClasses(B, C, D); // type: (props: { b: string; } & { c: string; } & { d: string; }) => B & C & D
const e = mergeE({ b: "b prop", c: "c prop", d: "d prop" }); // type: B & C & D
console.log(e);
Typescript Playground Link