My personal opinion is that it's a bad design to have a class TemplateCreator who depends on a global constant AvailableTemplate. Either move the template mapping to inside of the class or take it as an argument to the constructor.
The next consideration is how strictly typed we want this to be in terms of differentiating between the template types. We know that we will always get a Template. Do we care if it's a FirstTemplate or SecondTemplate? I generally veer on the side of over-typing.
We require the following things from any class in the AvailableTemplate mapping:
- It can be instantiated by calling
new without any arguments
- It has a
create method which return a Template
We will use a generic to describe our map object and make sure that it extends those requirements. When we say that a type extends Record<string, Value> we can restrict the values to Value but still get access to our type's specific keys. Our type extends the Record but it is not a Record itself and has no index signature.
We get better type inference when going from the instances to the classes instead of the other way around (due to the nature of extends), so our class will depend on the type of its factories.
class TemplateCreator<T extends Record<string, BaseFactory>> {
private factories: T;
We will use a mapped type to convert from a mapping of instances to a mapping of classes/constructors.
type ToConstructors<T> = {
[K in keyof T]: new() => T[K];
}
The actual mapping of an object in the code always requires some assertion because Object.keys and Object.entries use the type string instead of keyof T (which is by design, but can be very annoying).
constructor(classes: ToConstructors<T>) {
// we have to assert the type here
// unless you want to pass in instances instead of classes
this.factories = Object.fromEntries(
Object.entries(classes).map(
([key, value]) => ([key, new value()])
)
) as T;
}
The second assertion I think can be avoided, but for right now I haven't got the class itself to recognize the specific type of the created template based on the key. If we want the public-facing API of the TemplateCreator to return the matching type we can take a shortcut here and just assert it.
// needs to be generic in order to get a specific template type
createForType<K extends keyof T>(type: K): ReturnType<T[K]['create']> {
return this.factories[type].create() as ReturnType<T[K]['create']>;
}
If we don't care about the specificity and are fine to just return Template always then we don't need to assertion or the generic. We can still restrict the keys.
createForType(type: keyof T): Template {
return this.factories[type].create();
}
Complete Code:
//------------------------BASE TYPES------------------------//
interface Template {
}
interface BaseFactory {
create(): Template;
}
type ToConstructors<T> = {
[K in keyof T]: new () => T[K];
}
//------------------------THE FACTORY------------------------//
class TemplateCreator<T extends Record<string, BaseFactory>> {
private factories: T;
constructor(classes: ToConstructors<T>) {
// we have to assert the type here
// unless you want to pass in instances instead of classes
this.factories = Object.fromEntries(
Object.entries(classes).map(
([key, value]) => ([key, new value()])
)
) as T;
}
// this method needs to be generic
// in order to get a specific template type
createForType<K extends keyof T>(type: K): ReturnType<T[K]['create']> {
// I think this can be improved so that we don't need to assert
// right now it is just `Template`
return this.factories[type].create() as ReturnType<T[K]['create']>;
}
}
//------------------------OUR CLASSES------------------------//
abstract class TemplateFactory {
abstract create(): Template;
}
interface FirstTemplate extends Template {
firstProp: string;
}
class FirstTemplateFactory extends TemplateFactory {
create(): FirstTemplate {
return { firstProp: "first" };
}
}
interface SecondTemplate extends Template {
secondProp: string;
}
class SecondTemplateFactory extends TemplateFactory {
create(): SecondTemplate {
return { secondProp: "second" };
}
}
// -----------------------TESTING-------------------------- //
const AvailableTemplate = Object.freeze({
first: FirstTemplateFactory,
second: SecondTemplateFactory,
});
const myTemplateCreator = new TemplateCreator(AvailableTemplate);
const first = myTemplateCreator.createForType('first'); // type: FirstTemplate
const second = myTemplateCreator.createForType('second'); // type: SecondTemplate
Typescript Playground Link