typescript

How to specify in TypeScript that a property is a typeof an object that extends another?


For context, I am writing some code and I want to pull in a list of modules to load from a configuration file. The module classes are all loaded in at start, and then using the list of modules to load we initialize the requested modules.

For the registry of modules, I have:

export abstract class Module {
    abstract initialize(config: any): void;
    abstract shutdown(): void;

    public static getModuleName(): string {
        throw new Error('Module must define a moduleName');
    }
}

All of the modules then later are imported via script and register themself similarly to this:

import {Module} from "../../framework/Module";
import {moduleRegistry} from "../../framework/ModuleRegistry";

class TwitchInitializer extends Module {
    public static MODULE_NAME: string = "twitch";

    public static getModuleName(): string {
        return TwitchInitializer.MODULE_NAME;
    }

    initialize(config: any): void {
    }

    shutdown(): void {
    }
}

moduleRegistry.register(TwitchInitializer);
import {Module} from "./Module";

class ModuleRegistry {
    modules: Record<string, typeof Module> = {};

    register<T extends typeof Module>(module: T): void {
        const moduleName = module.getModuleName();
        if (this.modules.hasOwnProperty(moduleName)) {
            throw `Module ${moduleName} already registered`;
        }
        this.modules[moduleName] = module;
    }

    get(moduleName: string): typeof Module {
        return this.modules[moduleName];
    }
}

const moduleRegistry = new ModuleRegistry();

export {moduleRegistry};

Later on I have

    const modules = [];

    // modulesToLoad is an array of strings, e.g. ['twitch']
    const modulesToLoad = configManager.get('MODULES').split(',');

modulesToLoad.forEach((moduleName) => {
    const moduleType = moduleRegistry.get(moduleName);
    if (moduleType === undefined) {
        logger.warn(`Could not load module of type ${moduleName}`);
        return;
    }
    modules.push(new moduleType);
})

This is all well and good, except that my IDE shows me:

TS2511: Cannot create an instance of an abstract class.

at new moduleType

Now, while I know it would run - I'd prefer to ensure I'm doing things the right way. What is the right way to declare that that modules contains a typeof of a non-abstract extension of Module?


Solution

  • Instead of typeof Module which only has an abstract construct signature and cannot be safely constructed, you need to use a type which is similar to that type but which has a regular (non-abstract, or "concrete") construct signature; furthermore that construct signature needs to accept zero arguments (since you write new moduleType() with no arguments).

    In some sense you want a type like (new () =>Module) & (typeof Module), the intersection of a concrete constructor and the type of Module, so that it has all the static properties you care about while also being constructible.

    Indeed you could use that type and everything would work:

    type ConcreteModuleCtor = (new () => Module) & (typeof Module);
    
    class ModuleRegistry {
      modules: Record<string, ConcreteModuleCtor> = {}; 
    
      register<T extends ConcreteModuleCtor>(module: T): void {
        const moduleName = module.getModuleName();
        if (this.modules.hasOwnProperty(moduleName)) {
          throw `Module ${moduleName} already registered`;
        }
        this.modules[moduleName] = module;
      }
    
      get(moduleName: string): ConcreteModuleCtor {
        return this.modules[moduleName]!;
      }
    }
    
    const modules = [];
    declare const modulesToLoad: string[];    
    modulesToLoad.forEach((moduleName) => {
      const moduleType = moduleRegistry.get(moduleName);
      if (moduleType === undefined) {
        console.warn(`Could not load module of type ${moduleName}`);
        return;
      }
      modules.push(new moduleType);
    })
    

    The particular way you write ConcreteModuleCtor is not incredibly important.

    If you want to make your requirements very explicit and minimal, you can define it as its own interface which only mentions the facets of the Module constructor you actually use:

    interface ConcreteModuleCtor {
      getModuleName(): string;
      new(): Module;
    }
    

    That accepts anything which constructs a subtype of Module with zero args, and also has a getModuleName() method that returns a string. It doesn't need to have any other static properties of Module, since they're not used.

    Or you could derive ConcreteModuleCtor from typeof Module by interface extension, as long as you give typeof Module a named type first:

    type AbstractModuleCtor = typeof Module;
    interface ConcreteModuleCtor extends AbstractModuleCtor {
      new(): Module;
    }
    

    Any of those approaches will work, and that's not necessarily an exhaustive list of them. None of the approaches is obviously "more correct" than the others. There might be use cases or preferences which make one approach more or less appropriate or appealing, but that's outside the scope of the question as asked.

    Playground link to code