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) */
Advertisement
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]);