TypeScript: Create generic anonymous class by providing class body

2k Views Asked by At

The title may be misleading, but I don't really know how to express my problem.

I want to provide a generic class (or just the class body) as a function argument. Then the provided class should infer its generics.

class Builder<EXT, INT = EXT> {
    // some builder stuff, which changes the generic T
    withBar(): Builder<EXT, INT & { bar: string }> {
      return this as any;
    }

    // here's the problem:
    build(clazz: MyClass<INT>): MyClass<EXT> {
       return wrap(clazz); // this method already exists and works
    }
}

Usage:

const builder = new Builder<{ foo: number }>();
// EXT = { foo: number }, INT = { foo: number }

builder = builder.withBar();
// EXT = { foo: number }, INT = { foo: number, bar: string }

builder.build(class { /* here I should be able to access this.foo and this.bar */ });
// Here I just want to provide the class body,
// because I don't want to type all the generics again.
// I don't want to specify `MyClass<?>`,
// because the correct type is already specified within the Builder

As an (ugly) "workaround" I found a way to provide all class methods separately and then build a class from it. Something like this:

class Builder<EXT, INT = EXT> {
    build(args: {method1?: any, method2?: any}): MyClass<EXT> {
       class Tmp {
         method1() {
           return args.method1 && args.method1(this);
         }
         method2(i: number) {
           return args.method2 && args.method2(this);
         }
       }

       return wrap(Tmp);
    }
}

But that's really ugly.

Basically I really just want to provide the class body to the build method. And then this method will create a class from it, call wrap and return.

Is there any way to do this?

EDIT: Another try to explain my problem:

At the moment I have to use the code like this:

builder.build(class extends AbstractClass<{ foo: number, bar: string }> {
    private prop: string;
    init() {
      this.prop = this.data.foo   // provided by AbstractClass
          ? 'foo'
          : 'bar'
    }
    getResult() {
      return {
        foo: this.prop,
        bar: this.data.bar  // provided by AbstractClass
      }
    }
})

As you can see I have to specify the generics of AbstractClass. I don't want to specify the type, because builder already knows the type.

I just want to provide the body of the class without specifying the generic type again. Something like this:

builder.build(class extends AbstractClass<infer the type with magic!> {
    ...
    getResult() {
        return { this.data.foo }
    }
})

Or this:

builder.build(class {
    ...
    getResult() {
        return { this.data.foo }
    }
})
2

There are 2 best solutions below

0
On BEST ANSWER

I just had a good idea. Which adds a bit of unused code, but fulfills the type inference.

The idea is pretty simple: Just use typeof!

class Builder<EXT, INT = EXT> {
    // some builder stuff, which changes the generic T
    withBar(): Builder<EXT, INT & { bar: string }> {
      return this as any;
    }

    build(clazz: (type: INT) => Constructor<INT>): MyClass<EXT> {
      return wrap(clazz(null as any) as any) as any;
    }
}

interface Constructor<T> {
    new (data: T): any;
}

Now it's possible to use the type: INT like this:

// here happens the "magic"
builder.build((type) => class extends AbstractClass<typeof type> {
    getResult() {
        return { this.data.foo }
    }
})

I'm not sure if there's any better solution.

4
On

I'm not 100% certain I understand what you want, or that what you want is doable. But here are some ideas...

First, what you're calling MyClass<T> seems to be referring to a class constructor which returns a T. This type can be represented by a constructor signature like this:

type Constructor<T> = new (...args: any[]) => T;

So perhaps Builder should be:

class Builder<EXT, INT = EXT> {
  // some builder stuff, which changes the generic T
  withBar(): Builder<EXT, INT & { bar: string }> {
    return this as any;
  }

  // here's maybe a solution:
  build(clazz: Constructor<INT>): Constructor<EXT> {
    return wrap(clazz) as any; // as any?  not sure
  }
}

Then, when you call it... first of all, you can't mutate types of values in TypeScript, so you can't reassign builder. But you can still do a chain (or give the intermediate values new names). I'll chain it. Here:

const builder = new Builder<{ foo: number }>();

const ctor = builder.withBar().build(class {
  foo = 10;
  bar = "you";
});

So this works. But note that you do need to define foo and bar inside the anonymous class body. TypeScript will be upset if you leave them out. So while you do "have access" to them, it might not be as convenient as you want?


Anyway, if that doesn't suffice please add more detail and maybe I can help more. Good luck!