Search code examples
typescriptgenericsdynamictyping

Typescript: How can one type a class registry variable?


I'm trying to build objects dynamically depending on user defined configuration (passed as a JSON or YAML). The keys are strings representing class names and the values constructor parameters.

The code defines a few classes that inherit from an abstract class and puts the classes in a registry where key = class name, value = class type. Each constructor uses object decomposition to get its parameters. A function then takes the configuration and for each key (class name):

  • looks up the corresponding type in the registry
  • creates a new instance of the class by calling the constructor with an object that is decomposed
  • calls a common function defined in the abstract base class

The first question that arises in how is the registry typed?

The second is how can the config be typed so that the constructors can still be called?

Below is example code that fails to compile. It takes the registry approach.

abstract class BaseClass {
    abstract doSomething(): void
}

// Param names are inconsequential
// They are here to show that constructor signatures vary
interface ComplexSomethingParam {
    key: string
    key2: number
}
interface SomethingParams {
    param1: string
    param2: number
    param3: ComplexSomethingParam
}

class Something extends BaseClass {
    readonly param1: string
    readonly param2: number
    readonly param3: ComplexSomethingParam
    constructor({ param1, param2, param3 }: SomethingParams) {
        super()
        this.param1 = param1
        this.param2 = param2
        this.param3 = param3
    }
    doSomething(): void {
        console.log("I am something !")
    }
}


interface ComplexAnotherParam {
    anotherKey: string
    anotherKey2: number
}
interface AnotherParams {
    anotherParam1: string
    anotherParam2: number
    anotherParam3: ComplexAnotherParam
}

class AnotherThing extends BaseClass {
    readonly anotherParam1: string
    readonly anotherParam2: number
    readonly anotherParam3: ComplexAnotherParam
    constructor({ anotherParam1, anotherParam2, anotherParam3 }: AnotherParams) {
        super()
        this.anotherParam1 = anotherParam1
        this.anotherParam2 = anotherParam2
        this.anotherParam3 = anotherParam3
    }
    doSomething(): void {
        console.log("I am another thing !")
    }
}

// To be consulted when dynamically creating objects
// Should store the class types and their names
// QUESTION: Which types should be used here
const REGISTRY: Record<string, BaseClass> = {
    Something,
    AnotherThing
}

// <class name>: <constructor parameters>
const CONFIG = {
    "Something": {
        "param1": "value",
        "param2": 1234,
        "param3": {
            "key1": "value",
            "key2": 456
        }
    },

    "AnotherThing": {
        "anotherParam1": "value",
        "anotherParam2": 1234,
        "anotherParam3": {
            "anotherKey1": "value",
            "anotherKey2": 456
        }
    }
}

// QUESTION: which types should be used for the config param?
function handleConfig(config) {
    for (const className of config) {
        const klass = REGISTRY[className]
        if (klass === undefined) {
            console.error(`Unknown permission class ${className}`)
            return
        }
        // END GOAL: successfully create an object and call the doSomething method!
        const instance = new klass(config[className])
        instance.doSomething()
    }
}

handleConfig(CONFIG)

Here the typescript playground link.


Solution

  • playground: https://tsplay.dev/NB8bxW

    const REGISTRY = {
        Something,
        AnotherThing
        // ensure this type is correct, without limiting its type
        // ensure all of them are single-argument constructors = BaseClass
    } satisfies Record<string, new (onlyArg: any) => BaseClass>
    
    // Map each key to constructor argument
    type ConfigType = { [K in keyof typeof REGISTRY]?: ConstructorParameters<typeof REGISTRY[K]>[0] }
    
    // <class name>: <constructor parameters>
    const CONFIG = {
        "Something": {
            "param1": "value",
            "param2": 1234,
            "param3": {
                "key1": "value", // error
                "key2": 456
            }
        },
    
        "AnotherThing": {
            "anotherParam1": "value",
            "anotherParam2": 1234,
            "anotherParam3": {
                "anotherKey1": "value", // error
                "anotherKey2": 456
            }
        }
        // ensure this type is correct, without limiting its type
    } satisfies ConfigType
    
    // type magic to better type the builtin function
    const recordEntries = Object.entries as (<T>(o: T) => ({ [K in keyof T]-?: [K, T[K]] }[keyof T])[])
    
    function handleConfig(
        config: ConfigType
    ) {
        for (const [className, constructorArg] of recordEntries(config)) {
            const klass = REGISTRY[className]
            if (klass === undefined) {
                console.error(`Unknown permission class ${className}`)
                return
            }
            // can't help here, have to anycast
            const instance = new klass(constructorArg as any)
            instance.doSomething()
        }
    }
    // ofc errors because CONFIG does not satisfy
    handleConfig(CONFIG)