Search code examples
typescriptdesign-patternsabstract-factory

How to construct TypeScript types for abstract factory pattern


I'm trying to get my head around how I can type the private factories: Record<...>, which will contain key value pairs of a aKey: aFactoryInstance. I've tried Record<string, TemplateFactory>, which has 2 issues; 1. They key is not just any string, but a specific one, and 2. TemplateFactory is the abstract class, and what I have as values are instances of derived classes from that abstract factory one.

I found this SO thread about create a factory class in typescript, which also had my second issue which is the:

Element implicitly has an 'any' type because...

But the comments in there were not applicable here, since they didn't really implement the abstract factory pattern, I got the impression of.

abstract class TemplateFactory {}
class FirstTemplateFactory extends TemplateFactory {}
class SecondTemplateFactory extends TemplateFactory {}

const AvailableTemplate = Object.freeze({
  first: FirstTemplateFactory,
  second: SecondTemplateFactory,
});

class TemplateCreator {
  private factories: Record<string, unknown>; // 👈 How to type this record? It will be an object of: { aKey: aFactoryInstance }

  constructor() {
    this.factories = {};
    Object.keys(AvailableTemplate).forEach((key) => {
      this.factories[key] = new AvailableTemplate[key](); // 👈  "Element implicitly has an 'any' type because expression of type 'string' can't be used to index"
    });
  }
}

Solution

  • 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