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
.
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!