I have the following class structure:
class App<objectList extends {}> {
private objects: Map<keyof objectList, any> = new Map();
add<T extends keyof objectList>(name: T, type: objectList[T]) {
this.objects.set(name, type);
this['get' name] = () => {
return type;
}
return this;
}
}
When I create a new instance of this class, I want to add additional objects to it, which later on I want to retrieve with the function getObjectType()
on the instance.
Example:
const App = new App<{Test: string, Test2: number}>().add('Test', 'this is my test string').add('Test2', 5);
App.getTest(); // returns 'this is my test string'
App.getTest2(); // returns 5
This works as expected, however typescript complains that the functions are inexistent. Would it be possible somehow to strongly type a simmilar situation?
UPDATE
Would it be possible somehow, to do the functionality of the add
function, directly in the constructor?
class App<objectList extends {}> {
constructor(initObjects: objectList) {
/** iterate over the initObjects, create the functions **/
}
}
const inst = new App<{Test: string, Test2: number}>({
'Test': 'this is my test string',
'Test2': 5
});
inst.getTest();
CodePudding user response:
For the question as originally asked:
The compiler doesn't really track type mutations, so we'll have to tell it what we're doing inside the body of add()
; specifically, we need to manually represent the type that we want to treat the returned this
as. Here's one approach:
add<K extends Extract<keyof O, string | number>>(name: K, type: O[K]) {
this.objects.set(name, type);
(this as any)['get' name] = () => {
return type;
}
return this as this & Record<`get${K}`, () => O[K]>
}
When you call add
with a name
of type K
and with a type
of type O[K]
(where O
is what you were calling objectsList
), the return value will be of type this & Record<`get${K}`, ()=>O[K]>
. That's an intersection type of both this
along with an object with a single property whose key is `get${K}`
(a template literal type you get by concatenating the string "get"
with the key K
) and whose value is a no-arg function that returns a value of type O[K]
. We have to use a type assertion to say that the returned this
value is of this augmented type, because the compiler can't track such things.
Anyway you can verify that it works as desired:
const app = new App<{ Test: string, Test2: number }>().
add('Test', 'this is my test string').
add('Test2', 5);
app.getTest(); // no error
app.getTest2(); // no error
On the other hand, if you want to skip the builder pattern and instead pass the whole object of type O
into the App
constructor, then you need the App
class instances to have dynamic keys which are not statically known; that is, App<O>
has keys that depend on O
. This is possible to describe, but not directly with class
or interface
declarations. First let's describe what the App
constructor type should look like:
new <O>(initObjects: O) => App<O>
It should have a construct signature that takes an initObjects
parameter of type O
, and return a value of type App<O>
, which is defined like this:
type App<O> = {
[K in keyof O as `get${Extract<K, string | number>}`]:
() => O[K]
};
That's a mapped type with remapped keys so that for each key K
in O
, App<O>
has a key with the same name with "get"
prepended to it. And the value of the property at that key is a no-arg function that returns a value of the property type from O
.
Again, we want to say that the App
constructor has that shape, but the compiler can't verify it. So we'll need to use another type assertion (and it's easier to use a class expression to avoid having to use a dummy class name):
var App = class App {
constructor(initObjects: any) {
Object.keys(initObjects).forEach(
k => (this as any)["get" k] = () => initObjects[k]
);
}
} as new <O>(initObjects: O) => App<O>;
See how the implementation of the inner App
class just copies each member of initObjects
into this
in the appropriate way. Let's test it:
const inst = new App({ Test: "this is my test string", Test2: 5 });
console.log(inst.getTest().toUpperCase());
Looks good!