typescripttypescript-definitions.d.ts

Define dynamically generated properties from an inherited class


I am trying to generate a d.ts declaration file for my project.

There are two classes that do calculate heavy logic, and the remaining classes inherit from them. Properties on the subclasses are not defined on the object itself but rather on a getter named defaults within the class and defined in runtime.

Base Models

abstract class BaseModel {}
abstract class Model extends BaseModel {}

Inherited Model

class Example extends Model {
    get defaults() {
       return {
            someProp: 1,
            anotherProp: 2
       }
    }
}

My code works perfectly but there is no autocomplete for the dynamically added properties. I tried adding the following to the d.ts file to make it aware of the dynamically properties but it didn't seem to work.

index.d.ts

class Model<T> extends BaseModel {
    [P in keyof T]: T[P]
}

type ExampleType = {
    someProp : number
    anotherProp : number
}

class Example extends Model<ExampleType> {}

How can I add property definition to inherited classes without having to manually define them?


Solution

  • You can't do this directly, you can do it if you create the class using an extra function which will mutate the creating class to add the proeprties:

    type ReplaceInstanceType<T extends new (...args: any[])=> any, TNewInstance> = 
        T extends new (...args: infer U)=> any ? 
            new (...args: U) => TNewInstance : 
            never;
    
    function createModel<T extends new (...args: any[])=> any>(modelClass: T) : 
        ReplaceInstanceType<T, InstanceType<T> & InstanceType<T>['defaults']> {
        return modelClass as any;
    }
    

    Note this uses the 3.0 feature Tuples in rest parameters and spread expressions for a 2.9, 2.8 version of ReplaceInstanceType see this answer

    We can use this in one of two ways:

    Use the output of createModel directly:

    const Example = createModel(class extends Model {
        constructor (data: string) {
            super();
            // this.anotherProp // Not ok we can't access teh extra fields inside the class
        }
        get defaults() {
            return {
                someProp: 1,
                anotherProp: 2
            }
        }
    });
    new Example(``).anotherProp // we can create the class and access the extra fields
    new Example("").someProp
    

    This has the disadvantage that the extra fields are not usable from within the class itself, which may be an issue in some situation.

    The second usage is to use createModel in the extends clause of the new class and only define the defaults in the class we pass to extends, adding extra methods in the outer class:

    class Example extends createModel(class extends Model {
        get defaults() {
            return {
                someProp: 1,
                anotherProp: 2
            }
        }
    }) {
        constructor(data: string) {
            super();
            this.anotherProp // new properties are accesible
        }
    };
    new Example(``).anotherProp // we can create the class and access the extra fields
    new Example("").someProp
    

    This methods has the disadvantage of actually creating 2 classes the outer one which we actually use and the inner one we use to add the defaults property.

    Edit

    Since typescript actually does a very good job with strongly typing and validating key names you could also consider using get/set methods instead, you get good code completion and type validation but it is a bit more verbose:

    abstract class BaseModel { 
        abstract get defaults();
        get<K extends keyof this['defaults']>(name: keyof this['defaults']): this['defaults'][K]{
            return this[name] as any;
        }
        set<K extends keyof this['defaults']>(name: keyof this['defaults'], value:  this['defaults'][K]) : void{
            this[name] =  value;
        }
    }
    
    class Example extends BaseModel {
        get defaults() {
            return {
                someProp: 1,
                anotherProp: 2
            }
        }
    
    };
    new Example().get('anotherProp')
    new Example().get("someProp") 
    new Example().set("someProp", 1) // 
    new Example().set("someProp", "1") // error wrong type
    new Example().get("someProp2")  // error