The challenge I'm currently working toward is the following:
some pseudo-code:
const result = api.call("get a model") // can be of model type: "One" "Two" ... "Ten" which all extends 'BaseModel'
// value can be one of these: "One" "Two" ... "Ten"
// will be a corresponding class for each 'modelType' which all extend a 'BaseClass'
const type:string = route.params.modelType
// createClass(type) - returns a class based on 'type' and 'result' is passed to its constructor
const newClass = new createClass(type)(result);
So I'm trying to dynamically create a new instance of a class based on a string value, hydrate that class's properties with a model passed in a constructor and pass that same model to the super class to hydrate properties up the class chain.
The problem is that when creating the class and trying to pass the model in it's constructor, I'm getting an understandable error when passing that object to the constructor:
Argument of type is not assignable to parameter of type.
I am just unsure how to resolve it, or if it is possible?
Here's where I'm at so far..
interface BaseModel {
name: string;
age: number
}
interface SubModel extends BaseModel {
color: string;
speed: number
}
interface SubModel2 extends BaseModel {
shoeSize: number;
height: number
}
class Base {
name = '';
age = 1;
constructor(model: BaseModel) {
this.name = model.name;
this.age = model.age;
}
}
class Sub extends Base {
color = ''
speed = 0;
constructor(model: SubModel) {
super(model);
this.color = model.color;
this.speed = model.speed;
}
}
class Sub2 extends Base {
shoeSize = 0;
height= 0;
constructor(model: SubModel2) {
super(model);
this.shoeSize = model.shoeSize;
this.height = model.height;
}
}
// ok:
// const baseClass = new Base({ name: 'John', age: 39 });
// const subClass = new Sub({ color: 'green', speed: 100, ...baseClass });
// const sub2Class = new Sub2({ shoeSize: 10.5, height: 69, ...baseClass });
const Store = {
['Base']: Base,
['Sub']: Sub,
['Sub2']: Sub2,
};
class DynamicMenuElement {
static instance: DynamicMenuElement;
public static get() {
if (!DynamicMenuElement.instance) {
DynamicMenuElement.instance = new DynamicMenuElement();
}
return DynamicMenuElement.instance;
}
// *Something* should be in place of 'U' ...
public createMenuElement<K extends keyof typeof Store, U extends BaseModel>(
type: K, model: U
) {
const Class = Store[type];
if (!Class) {
throw new Error(`Cannot find the class type of ${type}`);
}
return new Class(model); // error
}
}
const base = { name: 'John', age: 39 };
const sub = { color: 'green', speed: 100, ...base };
const sub2 = { shoeSize: 10.5, height: 69, ...base };
// implementation
const element1 = DynamicMenuElement.get().createMenuElement('Base', base);
const element2 = DynamicMenuElement.get().createMenuElement('Sub', sub);
const element3 = DynamicMenuElement.get().createMenuElement('Sub', sub2); // should error
For ease of discussion, I'm going to rename your Store
variable out of the way to _Store
(since we will later change its type), and then make a Store
type which is typeof _Store
(so we don't have to keep saying typeof _Store
):
const _Store = {
['Base']: Base,
['Sub']: Sub,
['Sub2']: Sub2,
};
type Store = typeof _Store;
const Store = _Store; // the type will change later
The first step is to give a type to the model
parameter of createMenuElement
:
public createMenuElement<K extends keyof Store>(
type: K, model: ConstructorParameters<Store[K]>[0]
) {
const Class = Store[type];
if (!Class) {
throw new Error(`Cannot find the class type of ${type}`);
}
return new Class(model);
}
Here I'm using the ConstructorParameters
utility type to pull out the expected parameter list for the given type
, and then indexing into it with 0
to get the first one.
But if we write it as above without changing the value of Store
, we still get an error in new Class(model)
, because TypeScript cannot follow the logic that Store[K]
takes a constructor parameter of type typeof model
for arbitrary generic K
. (It does know that for any specific K
type, like "Base"
or "Sub2"
, but for generic K
it doesn't see the higher-order truth of that). It ends up having the generic K
fall back to its constraint, and then you end up with a union of constructors, and you can't call a union of constructors with a union of constructor parameters, even though each union member of model
is appropriate for its corresponding union member of Class
. This failure to see the correlation of union types is the subject of micorosoft/TypeScript#30581.
The recommended approach in these cases is described at microsoft/TypeScript#47109, and involves refactoring in terms of some basic object types, mapped types over those interfaces, and generic indexes into those. The minimum change to this code to get it working looks like this:
const Store:
{ [K in keyof Store]: new (c: ConstructorParameters<Store[K]>[0]) =>
InstanceType<Store[K]> } = _Store;
Here I've written the type of the Store
variable explicitly as a mapped type over the Store
type. It is saying that Store
's type, for any generic arbitrary K
in keyof Store
, is a constructor that takes a single argument of type ConstructorParameters<Store[K]>[0]
, and returns a value of type InstanceType<Store[K]>
(using the InstanceType
utility type). The assignment of _Store
to Store
still works because TypeScript is able to verify that _Store
matches that type.
In some sense it looks like not much has happened. Indeed if you use IntelliSense to hover over the Store
variable you'll see that its type is displayed almost exactly the same: an object filled with constructors. But now the implementation of createMenuElement
works with no error, and the return type is InstanceType<Store[K]>
, so you get the expected results:
const element1 = DynamicMenuElement.get().createMenuElement('Base', base);
// ^? const element1: Base
const element2 = DynamicMenuElement.get().createMenuElement('Sub', sub);
// ^? const element2: Sub
const element3 = DynamicMenuElement.get().createMenuElement('Sub2', sub2);
// ^? const element3: Sub2
DynamicMenuElement.get().createMenuElement('Sub2', sub); // error
This works because inside createMenuElement
, TypeScript can see that the type of Store[type]
can be written purely in terms of K
as new (c: ConstructorParameters<Store[K]>[0]) => InstanceType<Store[K]>
, meaning that it takes a parameter of the same exact type as model
, and returns InstanceType<Store[K]>
. Again, the difference is that a mapped type represents explicitly the generic relationship that TypeScript cannot derive for itself.
These types are a bit ugly, so it might be helpful to refactor into more descriptive names:
type StoreParams = { [K in keyof typeof _Store]:
ConstructorParameters<typeof _Store[K]>[0]
}
type StoreInstance = {
[K in keyof typeof _Store]: InstanceType<typeof _Store[K]>
}
const Store: {
[K in keyof StoreParams]: new (x: StoreParams[K]) => StoreInstance[K]
} = _Store;
public createMenuElement<K extends keyof StoreParams>(
type: K, model: StoreParams[K]
): StoreInstance[K] {
const Class = Store[type];
if (!Class) {
throw new Error(`Cannot find the class type of ${type}`);
}
return new Class(model);
}
These are the same as before, but now instead of the Store
type, we have StoreParams
and StoreInstance
types, and the type of Store
refers to them. So model
is of type StoreParams[K]
(which should make it more obvious what's going on there) and the return type is StoreInstance[K]
(also more obvious). You can view this as defining two basic object types corresponding to the constructor parameters and the instance types, and then making Store
's type a mapped type over those basic types. And then createMenuElement
operates in terms of generic indexed accesses into those types. Which is the general approach outlined in microsoft/TypeScript#47109.