Skip to content
Advertisement

Typescript: type of subclasses of an abstract generic class

I have a Base generic class:

abstract class BaseClass<T> {
  abstract itemArray: Array<T>;

  static getName(): string {
    throw new Error(`BaseClass - 'getName' was not overridden!`);
  }

  internalLogic() {}
}

and inheritors:

type Item1 = {
  name: string
}
class Child1 extends BaseClass<Item1> {
  itemArray: Array<Item1> = [];
  static getName(): string {
    return "Child1";
  }
}


type Item2 = {
  name: number
}
class Child2 extends BaseClass<Item2> {
  itemArray: Array<Item2> = [];
  static getName(): string {
    return "Child2";
  }
}

Now I want to have to define an object with the inheritors as its values:

type IChildrenObj = {
  [key: string]: InstanceType<typeof BaseClass>;
};

/* 
The following error is received: Type 'typeof BaseClass' does not satisfy the constraint 'new (...args: any) => any'.
  Cannot assign an abstract constructor type to a non-abstract constructor type. ts(2344)
*/

const Children: IChildrenObj = {
  C1: Child1,
  C2: Child2,
}

Lastly, I want to be able to use statics methods of the children, and also be able to create instances of them:

const child: typeof BaseClass = Children.C1;
/*
received the following error: Property 'prototype' is missing in type '{ getName: () => string; }' but required in type 'typeof BaseClass'. ts(2741)
*/

console.log(child.getName());
const childInstance: BaseClass = new child();
/*
The following 2 errors are received:
(1) Generic type 'BaseClass<T>' requires 1 type argument(s). ts(2314)
(2) Cannot create an instance of an abstract class. ts(2511)
Generic type 'BaseClass<T>' requires 1 type argument(s). ts(2314)
*/

Answer

Firstly, the type

type IChildrenObj = {
  [key: string]: InstanceType<typeof BaseClass>; // instances?
};

is not appropriate to describe your Children object. Children stores class constructors while InstanceType<typeof BaseClass>, even if it worked for abstract classes (which, as you noted, it doesn’t), would be talking about class instances. It would be closer to write

type IChildrenObj = {
  [key: string]: typeof BaseClass; // more like constructors
};

But that is also not what Children stores:

const Children: IChildrenObj = {
  C1: Child1, // error!
  // Type 'typeof Child1' is not assignable to type 'typeof BaseClass'.
  // Construct signature return types 'Child1' and 'BaseClass<T>' are incompatible.
  C2: Child2, // error!
  // Type 'typeof Child2' is not assignable to type 'typeof BaseClass'.
  // Construct signature return types 'Child2' and 'BaseClass<T>' are incompatible.
}

The type typeof BaseClass has an abstract construct signature that looks something like new <T>() => BaseClass<T>; the callers (or more usefully, the subclasses that extend BaseClass) can choose anything they want for T, and BaseClass must be able to handle that. But the types typeof Child1 and typeof Child2 are not able to produce BaseClass<T> for any T that the caller of new Child1() or the extender class Grandchild2 extends Child2 wants; Child1 can only construct a BaseClass<Item1> and Child2 can only construct a BaseClass<Item2>.

So currently IChildrenObj says it holds constructors that can each produce a BaseClass<T> for every possible type T. Really what you’d like is for IChildrenObj to say it holds constructors that can each produce a BaseClass<T> for some possible type T. That difference between “every” and “some” has to do with the difference between how the type parameter T is quantified; TypeScript (and most other languages with generics) only directly supports “every”, or universal quantification. Unfortunately there is no direct support for “some”, or existential quantification. See microsoft/TypeScript#14446 for the open feature request.

There are ways to accurately encode existential types in TypeScript, but these are probably a little too annoying to use unless you really care about type safety. (But I can elaborate if this is needed)

Instead, my suggestion here is probably to value productivity over full type safety and just use the intentionally loose any type to represent the T you don’t care about.


So, here’s one way to define IChildrenObj:

type SubclassOfBaseClass =
  (new () => BaseClass<any>) & // a concrete constructor of BaseClass<any>
  { [K in keyof typeof BaseClass]: typeof BaseClass[K] } // the statics without the abstract ctor

/* type SubclassOfBaseClass = (new () => BaseClass<any>) & {
    prototype: BaseClass<any>;
    getName: () => string;
} */

type IChildrenObj = {
  [key: string]: SubclassofBaseClass
}

The type SubclassOfBaseClass is the intersection of: a concrete construct signature that produces BaseClass<any> instances; and a mapped type which grabs all the static members from typeof BaseClass without also grabbing the offending abstract construct signature.

Let’s make sure it works:

const Children: IChildrenObj = {
  C1: Child1,
  C2: Child2,
} // okay

const nums = Object.values(Children)
  .map(ctor => new ctor().itemArray.length); // number[]
console.log(nums); // [0, 0]

const names = Object.values(Children)
  .map(ctor => ctor.getName()) // string[]
console.log(names); // ["Child1", "Child2"]

Looks good.


The caveat here is that, while IChildrenObj will work, it’s too fuzzy of a type to keep track of things you might care about, such as the particular key/value pairs of Children, and especially the weird “anything goes” behavior of index signatures and the any in BaseClass<any>:

// index signatures pretend every key exists:
try {
  new Children.C4Explosives() // compiles okay, but
} catch (err) {
  console.log(err); // 💥 RUNTIME: Children.C4Explosives is not a constructor
}

// BaseClass<any> means you no longer care about what T is:
new Children.C1().itemArray.push("Hey, this isn't an Item1") // no error anywhere

So my suggestion in cases like this is to only make sure that Children is assignable to IChildrenObj without actually annotating it as such. For example, you can use a helper function:

const asChildrenObj = <T extends IChildrenObj>(t: T) => t;

const Children = asChildrenObj({
  C1: Child1,
  C2: Child2,
}); // okay

Now Children can still be used anywhere you need an IChildrenObj, but it still remembers all of the specific key/value mappings, and thus emits errors when you do bad things:

new Children.C4Explosives() // compiler error!
//Property 'C4Explosives' does not exist on type '{ C1: typeof Child1; C2: typeof Child2; }'

new Children.C1().itemArray.push("Hey, this isn't an Item1") // compiler error!
// Argument of type 'string' is not assignable to parameter of type 'Item1'

You can still use IChildrenObj if you need to:

const anotherCopy: IChildrenObj = {};
(Object.keys(Children) as Array<keyof typeof Children>)
  .forEach(k => anotherCopy[k] = Children[k]);

Playground link to code

Advertisement