Using Generic Class Types in Generics

Viewed 192

I have the following code:

class Entity {
    id: number
}

class ChildEntity extends Entity {
    child_property: number;
}

class ChildEntity2 extends Entity {
    child_property_2: number;
}

class Data<T extends Entity> {
    protected entity: T
    static method() {
    }
}

class ChildData extends Data<ChildEntity> {
    childMethod() {
        this.entity.child_property = 1;
    }
}

class ChildData2 extends Data<ChildEntity2> {
    childMethod() {
        this.entity.child_property_2 = 1;
    }
}

function doStuffAndInstantiate(Input /* What should be the type here??? */ ) {
// function doStuffAndInstantiate<T extends Entity>(Input: new ()=> Data<T> ) { /* Error at Input.method() call: Property 'method' does not exist on type 'new () => Data '. */
// function doStuffAndInstantiate(Input: typeof Data ) { /* error at doStuffAndInstantiate(ChildData) call: 'ChildEntity' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Entity'. */
    Input.method();
    return new Data();
}

doStuffAndInstantiate(ChildData);
doStuffAndInstantiate(ChildData2);

What should be the type of Input argument of doStuffAndInstantiate? I've tried many ways with absolutely no success :(

3 Answers

What do the errors you got mean?

I think you're getting a little bit confused:

  • function doStuffAndInstantiate<T extends Entity>(Input: new ()=> Data<T>)

Doesnt compile because it's misusing the keyword new. new in typescript is used to create instances of a class as you're doing in your function implementation. As far as my understanding goes, new doesn't make a lot of sense inside a type annotation (the type annotation of your parameter) (unless you can teach me something new :)!)

Error at Input.method() call: Property 'method' does not exist on type 'new () => Data '

() => Data it's a type dictating:

"A function with no parameters that returns an object from the class Data"

So since it's a function it doesn't have any properties named method that are invokable. (From what you've explicitly provided in your sample code)

  • function doStuffAndInstantiate(Input: typeof Data )

What you've just declared with this line is:

"A function that takes one parameter named 'Input' which is a reference to the class Data and returns nothing"

error at doStuffAndInstantiate(ChildData) call: 'ChildEntity' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Entity'.

Which is true since ChildData nor ChildData2 are a reference to the class Data.

My attempt on providing a solution based on what's on the question on 26/07 at 10:46

I still have a hard time figuring out what you're trying to do. You're trying to call a static method from Data but you're passing in the class references from ChildData and ChildData2 which ARE NOT related what-so-ever with Data since they just extend Entity.

As per now, what I can provide you with is the following:

function doStuffAndInstantiate<T extends Entity>(): Data<T> {
  // Since it's a static method you don't need to pass a parameter to call it
  Entity.method()
  // We're getting the generic type from the function
  return new Data<T>();
}

Which you could just consume as:

const childEntity = doStuffAndInstantiate<ChildEntity>(); // returns Data<ChildEntity>
const childEntity2 = doStuffAndInstantiate<ChildEntity2>(); // returns Data<ChildEntity2>

What I've just declared is:

"A generic function that takes no parameters that returns an object of the class Data with the given generic type T"

And that is as far as the compiler may go without any annotation or preprocessor magic that would search for a valid candidate that has a suitable constructor.

If you like to get direct instances of your subclasses ChildData and ChildData2you'd need toswitchorif-elseif-elsethrough all the classes that you any be interested in returning. Since this would be done at runtime you can't properly type-annotate it because typescript won't let you useT` as a value because it refers to a type (which don't exist when transpiled to JavaScript).

It'd look something like:

function doStuffAndInstantiate(classOfSomething: unknown): any | null {

    switch(classOfSomething) {
        case ChildData2: 
            return new ChildData2();
        case ChildData: 
            return new ChildData();
        default: 
            return null;
    }
   

}

or you can just declare it as instantiable?

export type Abstract<T = any> = Function & { prototype: T };
export type Instantiable<T = any> = new (...args: any[]) => T;
export type Class<T = any> = Abstract<T> & Instantiable<T>;


function  doStuffAndInstantiate<E extends Entity, T extends Data<E>>(classOfSomething: Class<T> & { method: () => void }): T {
    classOfSomething.method();
    return new classOfSomething();
}

It's a tad hard to guess what you're looking for hehe

You'll want to declare that you want an object that's callable with new and returns your Data<T> (new () => Data<T>), and also has a property called method that is a function that takes no arguments with an ignored return value (() => void).

Give this a try:

function doStuffAndInstantiate<T extends Entity>(Input: { method(): void; new(): Data<T> }): Data<T> {
  Input.method();
  return new Input(); // You'll also want to instantiate "Input", not "Data" directly
}

Edit: Or, if you want to get more generic, you can produce a more accurate return type:

function doStuffAndInstantiate<
  T extends Entity,
  U extends Data<T>,
  V extends { method(): void; new(): U },
  W = V extends { new(): infer W } ? W : never
>(Input: V): W {
  Input.method();
  return new Input() as unknown as W;
}

// TS is happy with this now
doStuffAndInstantiate(ChildData2).childMethod();

The new ()=> Data<T> type fails, as this type definition does not include the statics of your Data class. typeof Data fails, because the prototype properties differ between Data and ChildData.

One approach is to exclude the prototype property and then add back in the Data<T> constraints.

function doStuffAndInstantiate<
  T extends Entity,
  R extends (new () => Data<T>) & Omit<typeof Data, 'prototype'>
>(Input: R): Data<T> {
  Input.method();
  return new Data();
}

If your child classes do not override any static methods of the Data class, it would be cleaner to use Data's methods directly:

function doStuffAndInstantiate<T extends Entity>(Input: new () => Data<T>){
    Data.method();
    return new Input();
}
Related