2

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();
7
  • 1
    Does this code meet your needs? If so I can write up an answer; if not, please edit your example with a failing use case. Let me know. Commented Oct 14, 2021 at 20:03
  • @jcalz yes, that is exactly what I need! Commented Oct 14, 2021 at 20:30
  • @jcalz would it be possible somehow, to do the same thing directly with the constructor? I updated the question with the potential modification requests Commented Oct 14, 2021 at 20:55
  • Oh, you faked me out by saying that's exactly what you need so that partway into writing an answer you've updated what you need? Oh well! I don't see the update yet. Maybe I'll come back later and look at this once it's completely stabilized. Commented Oct 14, 2021 at 20:56
  • @jcalz sorry, tought you already went to sleep and will answer only tommorrow:( the example code provided completely answers my original question btw! this is just some added functionality I am trying to achieve! Commented Oct 14, 2021 at 20:58

1 Answer 1

3

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):

const 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

Sign up to request clarification or add additional context in comments.

3 Comments

is there any reason that you used var istead of let or const when initiating the class constructor?
Not really; I think const is probably better so I’ll change it when I get a chance
Cool stuff, I didn't know about some features such as {template literal} while remapping keys, Extract<> to filter types, the class expression pattern, or as new to cast classes.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.