Comment créer une fabrique d'objets en conservant le type

Dec 23 2020

J'ai créé la fabrique d'objets suivante pour instancier les implémentations de toutes mes 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");

Comme vous pouvez le voir, cette usine vous permet d'instancier et de se moquer de ces interfaces. Le problème principal est que je dois appeler cette fonction avec le nom de l'interface ET l'interface pour garder le type dans les affectations:

ObjectFactory.getInstance<SomeInterface>("SomeInterface")

J'ai vu cette question , mais je n'aime pas l'idée d'utiliser une Baseinterface. De plus, cette approche ne garde pas non plus le type.

Idéalement, je voudrais utiliser mon approche mais sans avoir à utiliser l'interface, c'est-à-dire n'utiliser que le nom de l'interface.

Remarque: la déclaration de Injectableest un hack pour faire fonctionner ce code, idéalement, j'aimerais pouvoir utiliser uniquement le nom de l'implémentation, c'est-à-dire:

let DEFAULT_IMPLEMENTATIONS = {
    SomeInterface: Implementation
}

Réponses

1 jcalz Dec 23 2020 at 09:48

Puisque vous avez une liste fixe de mappages de nom à type que vous souhaitez prendre en charge, l'approche générale ici est de penser en termes de type d'objet Treprésentant ce mappage, puis pour tout nom d'interface pris en charge K extends keyof T, vous aurez affaire à des fonctions qui renvoie la propriété à ce nom ... à savoir, des fonctions de type () => T[K]. Une autre façon de dire cela est que nous allons utiliser keyofet rechercher des types pour aider à donner des types à votre usine.

Nous n'utiliserons qu'un type concret comme {"SomeInterface": SomeInterface; "Date": Date}for T, mais dans ce qui suit, le compilateur aura plus de facilité s'il Test générique. Voici une implémentation générique possible d'un ObjectFactoryfabricant:

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;
    }
  }
}

J'ai refactoré votre version en quelque chose que le compilateur peut principalement vérifier comme type sûr, afin d'éviter les assertions de type . Passons en revue.

La makeFactoryfonction est générique dans le type de mappage d'objet Tet prend un argument nommé DEFAULT_IMPLEMENTATIONSde type { [K in keyof T]: () => T[K] }. Il s'agit d'un type mappé dont les clés Ksont les mêmes que celles de Tmais dont les propriétés sont des fonctions à zéro argument qui renvoient une valeur de type T[K]. Vous pouvez voir comment votre existant DEFAULT_IMPLEMENTATIONSétait comme ceci: chaque propriété était une fonction zéro-arg renvoyant une valeur de l'interface correspondante.

À l'intérieur de l'implémentation de la fonction, nous créons MOCK_IMPLEMENTATIONS. Cette variable a presque le même type que DEFAULT_IMPLEMENTATIONSmais avec les propriétés facultatives (comme effectué par le modificateur d'option ?dans [K in keyof T]?).

La fonction renvoie la fabrique elle-même, qui a deux méthodes:

La getInstanceméthode est générique dans K, le type du nom de l'interface et la valeur de retour est de type T[K], la propriété d'interface correspondante. Je mets en œuvre cela en fusionnant DEFAULT_IMPLEMENTATIONSet MOCK_IMPLEMENTATIONSvia la propagation d'objets , et en annotant qu'il compositeInjectables'agit du même type que DEFAULT_IMPLEMENTATIONS. Ensuite, nous y indexons avec le interfaceNameet l'appelons.

La mockWithInstanceméthode est également générique dans K, le type du nom de l'interface, et accepte un paramètre de type K(le nom de l'interface) et un paramètre de type T[K](l'interface correspondante).


Voyons-le en 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

Tout cela fonctionne comme je pense que vous vous y attendez. On fait ObjectFactoryen appelant makeFactoryavec l' DEFAULT_IMPLEMENTATIONSobjet désiré . Ici, j'ai annoté que la SomeInterfacepropriété renvoie une valeur de type SomeInterface(sinon le compilateur en déduirait Implementationce qui peut être plus spécifique que vous ne le souhaiteriez).

Ensuite, nous pouvons voir que le compilateur nous permet d'appeler ObjectFactory.getInstance()et ObjectFactory.mockWithInstance()avec les arguments appropriés et de renvoyer les types attendus, et cela fonctionne également à l'exécution.


Lien Playground vers le code