Reputation: 749
Does anyone have a good fix on getting type checking when extending a class with dynamic methods? For example say you want to extend the class with methods based on options passed to the constructor. This is common in plain ol' JavaScript.
const defaults = {
dynamicMethods: ['method1', 'method2'];
};
class Hello {
constructor(options) {
options.dynamicMethods.forEach(m => this[m] = this.common);
}
private common(...args: any[]) {
// do something.
}
}
const hello = new Hello(defaults);
Of course the above will work and you'll be able to call these dynamic methods but you won't get intellisense.
Not you can solve this problem with something like the below:
class Hello<T> {
constructor(options) {
options.dynamicMethods.forEach(m => this[m] = this.common);
}
private common(...args: any[]) {
// do something.
}
}
interface IMethods {
method1(...args: any[]);
method2(...args: any[]);
}
function Factory<T>(options?): T & Hello<T> {
const hello = new Hello<T>(options);
return hello as T & Hello<T>;
}
To consume this:
import { Factory } from './some/path'
const hello = new Factory<IMethods>(defaults);
This of course works but wondering what other alternatives exist!
Upvotes: 1
Views: 3703
Reputation: 1574
Heavily inspired by @Oblosys's answer, but this solution also supports static methods from the original class via inheritance, avoids any
, and returns a constructible class rather than a function (which can't be called with new
in TS). It can also be extended indefinitely, so you could have many different plugins extend a logger and then still allow the user to extend it themselves.
// A simple type that allows extending the given class with given non-static methods
// We can type arguments to the constructor as an array, e.g. `[string]`
// This isn't amazingly clean, but it works
type ExtendedClass<Class, Methods, ArgsType extends unknown[] = []> = {
new (...args: ArgsType): Class & Methods;
};
class DynamicallyExtendableClass {
constructor(private name: string) {}
static testMethod() {
return "Blah";
}
dependent() {
return `Hello ${this.name}!`;
}
// We use a static method here because it doesn't refer to `this` (see https://eslint.org/docs/rules/class-methods-use-this)
static extend<Methods>(
newMethods: Methods
): ExtendedClass<DynamicallyExtendableClass, Methods, [string]> &
typeof DynamicallyExtendableClass {
// We create a new class that extends the class we're going to add new methods to
class Class extends this {
constructor(name: string) {
super(name);
// Then we assign those methods to the class's `this`
// This is all we need in JS, but TS won't support these types yet
Object.assign(this, newMethods);
}
}
// We convert the class's type based off the original class, extending with the new methods
// Finally, we add support for the static non-instance methods with `& typeof Class`
return Class as ExtendedClass<Class, Methods, [string]> & typeof Class;
}
}
// We can extend it with new methods
const Extended = DynamicallyExtendableClass.extend({
method1: (num: number) => num,
method2: (str: string) => str,
});
// Which gives us a new fully-fledged class
const ext = new Extended("Name");
const test1 = ext.method1(500);
const test2 = ext.method2("Test");
// ext.method3(); // This would throw a TypeScript error
// We have access to the static methods of the original class by inheritance
const test3 = Extended.testMethod();
// And we can extend as many times as we want!
const ExtExt = Extended.extend({
blah: (str: string) => `Blah: ${str}`,
});
const test4 = new ExtExt("Name").blah("Test");
console.log(test1, test2, test3, test4, new ExtExt("Name").dependent()); // Outputs: `500 Test Blah Blah: Test`
Upvotes: 0
Reputation: 4600
There is an easier way. Use Object.assign
method:
class Test {
constructor() {
const dynamic_property_name = "hello_world"
Object.assign(this, {
[dynamic_property_name]: "new value"
})
}
}
console.log(new Test())
Output
Test { hello_world: 'new value' }
Upvotes: 2
Reputation: 2848
From your example, you can get rid of the IMethods
interface and use the Record
type.
class Hello {
constructor(options: string[]) {
options.forEach(m => this[m] = this.common);
}
private common(...args: any[]) {
// do something.
}
}
function Factory<T extends string>(...options: T[]): Hello & Record<T, (...args) => any[]> {
const hello = new Hello(options);
return hello as Hello & Record<T, (...args) => any[]>;
}
const hello = Factory("method1", "method2");
hello.method1();
hello.method2();
Upvotes: 0
Reputation: 15106
After playing around with this a bit I came up with something that doesn't require declaring an interface for each extension:
interface ClassOf<T> {
new(...args: any[]) : T
}
const extendClass = <T,S>(class_ : ClassOf<T>, dynamicMethods : S) =>
(...args: any[]) => {
const o = new class_(args) as T & S;
for (const key of Object.keys(dynamicMethods) as Array<keyof S>) {
const method = dynamicMethods[key];
(o as S)[key] = method; // type sig seems unnecessary
}
return o;
}
// demo:
class Hello {
constructor(public name) {}
hello() {
return 'hello ' + this.name;
}
}
const extHelloConstr = extendClass(Hello, {incr: (x : number) => x + 1, show: (n : number) => 'nr ' + n});
const extHello = extHelloConstr('jimmy');
const test1 = extHello.incr(1);
const test2 = extHello.show(42);
const test3 = extHello.hello();
console.log(test1, test2, test3);
Except for the constructor arguments (which seem tricky,) all inferred types are correct. It even works when the code is executed. You can also return an anonymous class, but it's a bit weird to type those.
Not sure if this is what you're looking for, but perhaps it can serve as a source of inspiration.
Upvotes: 9