2

So I have a mergeClass function which takes various classes and merges them that way a variable will have all the properties of all those classes's members and it is all instanciated. The way I do this currently is I must have three classes to merge, but suppose I only need 2, or 5, or n number of classes that can be merged? How would I accomplish this? Basically I want the ability to pass in any number of classes. I'm beginning to think this is impossible, and if so, let me know. Thanks.

type Constructable = new (...args: any[]) => any;

const mergeClasses = <S extends Constructable, T extends Constructable, P extends Constructable>(class1: S, class2: T, class3: P) =>
    <Si extends InstanceType<S> = InstanceType<S>, 
    Ti extends InstanceType<T> = InstanceType<T>,
    Pi extends InstanceType<P> = InstanceType<P>>
    (args1: ConstructorParameters<S>, args2: ConstructorParameters<T>, args3: ConstructorParameters<P>): Si & Ti & Pi => {
        const obj1 = new class1(...args1);
        const obj2 = new class2(...args2);
        const obj3 = new class3(...args3);
        for (const p in obj2) {
            obj1[p] = obj2[p];
        }
        for(const p in obj3){
            obj1[p] = obj3[p];
        }
        return obj1 as Si & Ti & Pi;
};

//I need something like this maybe in my mergeClasses function?
for(let i = 0; i<classes.length; i++){
   let obj = classes[i]
   const obj1 = new obj();
   for (const p in obj) {
     obj1[p] = obj[p];
   } 
}

const mergeE = mergeClasses(B, C, D);
const e = mergeE<B, C, D>([], [], []);

Here is a Typescript Playground MergeClasses Function

Also, I'm risking a thumbs down for this question and if that is you, that's fine, you are more than welcome to thumbs me down, but at least let me know if this is even possible to do, that way I can at least continue to hack away at this with realistic expectations of success.

dragonore
  • 783
  • 2
  • 11
  • 25
  • I think it's doable if your generic describes a tuple type that exends an array of constructors. `const mergeClasses = (...classes: C)`. I'll play with it. – Linda Paiste Mar 19 '21 at 22:26

1 Answers1

2

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

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • Thanks Linda, I'm definitely going to go over this and really learn from it and inspect it more. My typescript isn't as good – dragonore Mar 19 '21 at 23:41