Search code examples
javascripttypescripttypesabstract-class

What's the best way to annotate dynamically loaded TypeScript abstract classes?


I'm writing a library that will let you plug in external implementations, and I'm trying to figure out the best way to write types for these.

Example

abstract class Animal {
    public abstract makeSounds();
}

class Dog extends Animal {
    public makeSounds() {
        console.log('woof');
    }
}

class Cat extends Animal {
    public makeSounds() {
        console.log('meow');
    }
}

type BuiltinAnimals = 'cat' | 'dog';

interface AnimalLike {
    [name: string]: new () => Animal;
}
default class ZooClient {
    public mostFamousAnimal: Animal;
    constructor(someAnimal: BuiltinAnimals | AnimalLike) {
        if (typeof someAnimal === 'string') {
            // if 'dog', load `Dog` and if 'cat', load `Cat`.
            // this.mostFamousAnimal = new Cat() or new Dog();
        } else {
           // load external animal plugin
           // this.mostFamousAnimal = new [someAnimal]();
        }
    }

    public makeSounds() {
        this.mostFamousAnimal.makeSounds();
    }
}

I want to expose a few built-in classes that can be readily used, or the user can bring their own class. How do I do this?

const zoo = new ZooClient('dog');
// or
const zoo = new ZooClient(new Dolphin()); // Or perhaps `new ZooClient(Dolphin)`?

I’m specifically looking at a neat way to be able to give nice options to users of ZooClient - the type information should let them know they can use a string (BuiltinAnimal) or a class that is their own implementation of Animal.


Solution

  • As an aside, right now your Cat and Dog types are structurally identical, meaning that the compiler can't tell the difference between them. This isn't necessarily a problem, but it does lead to some surprising results (e.g., IntelliSense might report that a Dog is of type Cat). For example code I usually like to avoid such unintentionally equivalent types, so I'll do this:

    class Dog extends Animal {
      chaseCars() {}
      public makeSounds() {
        console.log("woof");
      }
    }
    
    class Cat extends Animal {
      chaseMice() {}
      public makeSounds() {
        console.log("meow");
      }
    }
    

    Now a Cat and a Dog differ structurally (one can chaseMice() and the other can chaseCars()) as well as nominally (different names) and all is right with the world.


    So, I'd recommend creating a keyed registry of built-in Animal constructors:

    const builtInAnimals = {
      cat: Cat,
      dog: Dog
    };
    

    and an associated type:

    type BuiltInAnimals = typeof builtInAnimals;
    

    And then you can make your ZooClient class work like this:

    class ZooClient {
      public mostFamousAnimal: Animal;
      constructor(someAnimal: keyof BuiltInAnimals | (new () => Animal)) {
        const animalConstructor =
          typeof someAnimal === "string" ? builtInAnimals[someAnimal] : someAnimal;
        this.mostFamousAnimal = new animalConstructor();
      }
    
      public makeSounds() {
        this.mostFamousAnimal.makeSounds();
      }
    }
    

    So the input to the constructor is either keyof BuiltInAnimals (namely "cat" or "dog" in this example) or a constructor which returns some Animal. Then, the animalConstructor local variable uses a typeof type guard to distinguish what someAnimal is, and in either case is set to something of type new() => Animal. We then use that constructor as you'd expect.

    Let's see how it works:

    const dogZooClient = new ZooClient("dog");
    dogZooClient.makeSounds(); // woof
    
    class Dolphin extends Animal {
      makeSounds() {
        console.log("🐬🔊");
      }
    }
    const dolphinZooClient = new ZooClient(Dolphin);
    dolphinZooClient.makeSounds(); // 🐬🔊
    

    So that's the intended use, and it works. Let's make sure it doesn't have unintended uses:

    new ZooClient("badName"); // error!
    // Argument of type '"badName"' is not assignable to
    // parameter of type '"cat" | "dog" | (new () => Animal)'.
    
    class NotAnAnimal {
      makeSmells() {
        console.log("👃");
      }
    }
    new ZooClient(NotAnAnimal); // error!
    // Property 'makeSounds' is missing in type 'NotAnAnimal'
    // but required in type 'Animal'.
    

    Those are correctly rejected.


    Okay, hope that helps; good luck!

    Link to code