Home > other >  Tell typescript of dynamically defined functions
Tell typescript of dynamically defined functions

Time:10-15

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!

Playground link to code

  • Related