Antonio Gamiz Delgado
Antonio Gamiz Delgado

Reputation: 2103

How to make an Object Factory maintaining the type

I have created the following object factory to instantiate implementations of all my interfaces:

interface SomeInterface {
  get(): string;
}

class Implementation implements SomeInterface {
  constructor() {}
  get() {
    return "Hey :D";
  }
}

type Injectable = {
  [key: string]: () => unknown;
};

// deno-lint-ignore prefer-const
let DEFAULT_IMPLEMENTATIONS: Injectable = {
  SomeInterface: () => new Implementation(),
};

let MOCK_IMPLEMENTATIONS: Injectable = {};

class Factory {
  static getInstance(interfaceName: string, parameters: unknown = []) {
    if (MOCK_IMPLEMENTATIONS[interfaceName])
      return MOCK_IMPLEMENTATIONS[interfaceName]();
    return DEFAULT_IMPLEMENTATIONS[interfaceName]();
  }

  static mockWithInstance(interfaceName: string, mock: unknown) {
    MOCK_IMPLEMENTATIONS[interfaceName] = () => mock;
  }
}

export const ObjectFactory = {
  getInstance<T>(name: string): T {
    return Factory.getInstance(name) as T;
  },

  mockWithInstance: Factory.mockWithInstance,
};

const impl = ObjectFactory.getInstance<SomeInterface>("SomeInterface");

As you can see, this Factory lets you instantiation and mocking of those interfaces. The main problem is that I have to call this function with the name of the interface AND the interface to keep the type in the assignments:

ObjectFactory.getInstance<SomeInterface>("SomeInterface")

I have seen this question, but I do not like the idea of using a Base interface. Moreover, that approach does not keep the type neither.

Ideally, I would want to use my approach but without having to use the interface, that is, use only the name of the interface.

Side note: the declaration of Injectable is a hack to make that code work, ideally, I would like to be able to use only the implementation name, that is:

let DEFAULT_IMPLEMENTATIONS = {
    SomeInterface: Implementation
}

Upvotes: 1

Views: 340

Answers (1)

jcalz
jcalz

Reputation: 327859

Since you have a fixed list of name-to-type mappings you care about supporting, the general approach here is to think in terms of the object type T representing this mapping, and then for any supported interface name K extends keyof T, you will be dealing with functions that return the property at that name... namely, functions of type () => T[K]. Another way to say this is that we will be using keyof and lookup types to help give types to your factory.

We will only be using a concrete type like {"SomeInterface": SomeInterface; "Date": Date} for T, but in what follows the compiler has an easier time of things if T is generic. Here's a possible generic implementation of an ObjectFactory maker:

function makeFactory<T>(DEFAULT_IMPLEMENTATIONS: { [K in keyof T]: () => T[K] }) {
  const MOCK_IMPLEMENTATIONS: { [K in keyof T]?: () => T[K] } = {};
  return {
    getInstance<K extends keyof T>(interfaceName: K) {
      const compositeInjectable: typeof DEFAULT_IMPLEMENTATIONS = {
        ...DEFAULT_IMPLEMENTATIONS,
        ...MOCK_IMPLEMENTATIONS
      };
      return compositeInjectable[interfaceName]();
    },
    mockWithInstance<K extends keyof T>(interfaceName: K, mock: T[K]) {
      MOCK_IMPLEMENTATIONS[interfaceName] = () => mock;
    }
  }
}

I refactored your version into something the compiler can mostly verify as type safe, so as to avoid type assertions. Let's walk through it.

The makeFactory function is generic in the object mapping type T, and takes an argument named DEFAULT_IMPLEMENTATIONS of type { [K in keyof T]: () => T[K] }. This is a mapped type whose keys K are the same as those of T but whose properties are zero-arg functions that return a value of type T[K]. You can see how your existing DEFAULT_IMPLEMENTATIONS was like this: each property was a zero-arg function returning a value of the corresponding interface.

Inside the function implementation, we create MOCK_IMPLEMENTATIONS. This variable has almost the same type as DEFAULT_IMPLEMENTATIONS but with the properties being optional (as effected by the optionality modifier ? in [K in keyof T]?).

The function returns the factory itself, which has two methods:

The getInstance method is generic in K, the type of the interface name, and the return value is of type T[K], the corresponding interface property. I implement this by merging DEFAULT_IMPLEMENTATIONS and MOCK_IMPLEMENTATIONS via object spread, and annotating that this compositeInjectable is the same type as DEFAULT_IMPLEMENTATIONS. Then we index into it with the interfaceName and call it.

The mockWithInstance method is also generic in K, the type of the interface name, and accepts a parameter of type K (the interface name), and a parameter of type T[K] (the corresponding interface).


Let's see it in action:

const ObjectFactory = makeFactory({
  SomeInterface: (): SomeInterface => new Implementation(),
  Date: () => new Date()
});

console.log(ObjectFactory.getInstance("SomeInterface").get().toUpperCase()); // HEY :D
ObjectFactory.mockWithInstance("SomeInterface", { get: () => "howdy" });
console.log(ObjectFactory.getInstance("SomeInterface").get().toUpperCase()); // HOWDY
console.log(ObjectFactory.getInstance("Date").getFullYear()); // 2020

This all works as I think you expect. We make ObjectFactory by calling makeFactory with the desired DEFAULT_IMPLEMENTATIONS object. Here I have annotated that the SomeInterface property returns a value of type SomeInterface (otherwise the compiler would infer Implementation which may be more specific than you'd like).

Then we can see that the compiler lets us call ObjectFactory.getInstance() and ObjectFactory.mockWithInstance() with the proper arguments and returning the expected types, and it also works at runtime.


Playground link to code

Upvotes: 2

Related Questions