Cómo hacer una Fábrica de Objetos manteniendo el tipo
He creado la siguiente fábrica de objetos para crear instancias de implementaciones de todas mis 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");
Como puede ver, esta fábrica le permite crear instancias y burlarse de esas interfaces. El principal problema es que tengo que llamar a esta función con el nombre de la interfaz Y la interfaz para mantener el tipo en las asignaciones:
ObjectFactory.getInstance<SomeInterface>("SomeInterface")
He visto esta pregunta , pero no me gusta la idea de usar una Base
interfaz. Además, ese enfoque tampoco mantiene el tipo.
Idealmente, me gustaría usar mi enfoque pero sin tener que usar la interfaz, es decir, usar solo el nombre de la interfaz.
Nota al margen: la declaración de Injectable
es un truco para que ese código funcione, idealmente, me gustaría poder usar solo el nombre de implementación, es decir:
let DEFAULT_IMPLEMENTATIONS = {
SomeInterface: Implementation
}
Respuestas
Dado que tiene una lista fija de asignaciones de nombre a tipo que le interesa admitir, el enfoque general aquí es pensar en términos del tipo de objeto que T
representa esta asignación, y luego, para cualquier nombre de interfaz compatible K extends keyof T
, estará tratando con funciones que devuelve la propiedad con ese nombre ... es decir, funciones de tipo () => T[K]
. Otra forma de decir esto es que keyofusaremos tipos de búsqueda para ayudar a dar tipos a su fábrica.
Solo usaremos un tipo concreto como {"SomeInterface": SomeInterface; "Date": Date}
for T
, pero en lo que sigue, el compilador lo tiene más fácil si T
es genérico. Aquí hay una posible implementación genérica de un ObjectFactory
creador:
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;
}
}
}
Refactoricé su versión en algo que el compilador pueda verificar principalmente como tipo seguro, para evitar afirmaciones de tipo . Vamos a recorrerlo.
La makeFactory
función es genérica en el tipo de mapeo de objetos T
y toma un argumento llamado DEFAULT_IMPLEMENTATIONS
de tipo { [K in keyof T]: () => T[K] }
. Este es un tipo mapeado cuyas claves K
son las mismas que las de T
pero cuyas propiedades son funciones de cero argumentos que devuelven un valor de tipo T[K]
. Puede ver cómo su existente DEFAULT_IMPLEMENTATIONS
era así: cada propiedad era una función de cero argumentos que devolvía un valor de la interfaz correspondiente.
Dentro de la implementación de la función, creamos MOCK_IMPLEMENTATIONS
. Esta variable tiene casi el mismo tipo que, DEFAULT_IMPLEMENTATIONS
pero las propiedades son opcionales (como se ve afectado por el modificador de opcionalidad ?
en [K in keyof T]?
).
La función devuelve la propia fábrica, que tiene dos métodos:
El getInstance
método es genérico en K
, el tipo del nombre de la interfaz y el valor de retorno es de tipo T[K]
, la propiedad de la interfaz correspondiente. Implemento esto fusionando DEFAULT_IMPLEMENTATIONS
y MOCK_IMPLEMENTATIONS
mediante la extensión de objetos , y anoto que este compositeInjectable
es del mismo tipo que DEFAULT_IMPLEMENTATIONS
. Luego lo indexamos con el interfaceName
y lo llamamos.
El mockWithInstance
método también es genérico en K
, el tipo del nombre de la interfaz, y acepta un parámetro de tipo K
(el nombre de la interfaz) y un parámetro de tipo T[K]
(la interfaz correspondiente).
Veámoslo en acción:
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
Todo esto funciona como creo que esperas. Hacemos ObjectFactory
llamando makeFactory
con el DEFAULT_IMPLEMENTATIONS
objeto deseado . Aquí he anotado que la SomeInterface
propiedad devuelve un valor de tipo SomeInterface
(de lo contrario, el compilador inferiría Implementation
cuál puede ser más específico de lo que le gustaría).
Entonces podemos ver que el compilador nos permite llamar ObjectFactory.getInstance()
y ObjectFactory.mockWithInstance()
con los argumentos adecuados y devolver los tipos esperados, y también funciona en tiempo de ejecución.
Enlace del patio de juegos al código