Comment créer une fabrique d'objets en conservant le type
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 Base
interface. 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 Injectable
est 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
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 T
repré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 T
est générique. Voici une implémentation générique possible d'un ObjectFactory
fabricant:
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 makeFactory
fonction est générique dans le type de mappage d'objet T
et prend un argument nommé DEFAULT_IMPLEMENTATIONS
de type { [K in keyof T]: () => T[K] }
. Il s'agit d'un type mappé dont les clés K
sont les mêmes que celles de T
mais 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_IMPLEMENTATIONS
mais 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 getInstance
mé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_IMPLEMENTATIONS
et MOCK_IMPLEMENTATIONS
via la propagation d'objets , et en annotant qu'il compositeInjectable
s'agit du même type que DEFAULT_IMPLEMENTATIONS
. Ensuite, nous y indexons avec le interfaceName
et l'appelons.
La mockWithInstance
mé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 ObjectFactory
en appelant makeFactory
avec l' DEFAULT_IMPLEMENTATIONS
objet désiré . Ici, j'ai annoté que la SomeInterface
propriété renvoie une valeur de type SomeInterface
(sinon le compilateur en déduirait Implementation
ce 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