Cómo hacer una Fábrica de Objetos manteniendo el tipo

Dec 23 2020

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 Baseinterfaz. 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 Injectablees 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

1 jcalz Dec 23 2020 at 09:48

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 Trepresenta 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 Tes genérico. Aquí hay una posible implementación genérica de un ObjectFactorycreador:

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 makeFactoryfunción es genérica en el tipo de mapeo de objetos Ty toma un argumento llamado DEFAULT_IMPLEMENTATIONSde tipo { [K in keyof T]: () => T[K] }. Este es un tipo mapeado cuyas claves Kson las mismas que las de Tpero cuyas propiedades son funciones de cero argumentos que devuelven un valor de tipo T[K]. Puede ver cómo su existente DEFAULT_IMPLEMENTATIONSera 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_IMPLEMENTATIONSpero 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 getInstancemé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_IMPLEMENTATIONSy MOCK_IMPLEMENTATIONSmediante la extensión de objetos , y anoto que este compositeInjectablees del mismo tipo que DEFAULT_IMPLEMENTATIONS. Luego lo indexamos con el interfaceNamey lo llamamos.

El mockWithInstancemé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 ObjectFactoryllamando makeFactorycon el DEFAULT_IMPLEMENTATIONSobjeto deseado . Aquí he anotado que la SomeInterfacepropiedad devuelve un valor de tipo SomeInterface(de lo contrario, el compilador inferiría Implementationcuá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