Como fazer uma Fábrica de Objetos mantendo o tipo

Dec 23 2020

Eu criei a seguinte fábrica de objetos para instanciar implementações de todas as minhas 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 você pode ver, este Factory permite a instanciação e simulação dessas interfaces. O principal problema é que tenho que chamar essa função com o nome da interface E a interface para manter o tipo nas atribuições:

ObjectFactory.getInstance<SomeInterface>("SomeInterface")

Já vi essa pergunta , mas não gosto da ideia de usar uma Baseinterface. Além disso, essa abordagem também não mantém o tipo.

Idealmente, eu gostaria de usar minha abordagem, mas sem precisar usar a interface, ou seja, usar apenas o nome da interface.

Nota lateral: a declaração de Injectableé um hack para fazer esse código funcionar, de preferência, gostaria de poder usar apenas o nome da implementação, ou seja:

let DEFAULT_IMPLEMENTATIONS = {
    SomeInterface: Implementation
}

Respostas

1 jcalz Dec 23 2020 at 09:48

Uma vez que você tem uma lista fixa de mapeamentos de nome para tipo que deseja suportar, a abordagem geral aqui é pensar em termos do tipo de objeto que Trepresenta esse mapeamento e, em seguida, para qualquer nome de interface com suporte K extends keyof T, você estará lidando com funções que retornar a propriedade com esse nome ... ou seja, funções do tipo () => T[K]. Outra maneira de dizer isso é que usaremos keyofe pesquisaremos tipos para ajudar a fornecer tipos para sua fábrica.

Estaremos usando apenas um tipo concreto como o {"SomeInterface": SomeInterface; "Date": Date}for T, mas a seguir o compilador terá um tempo mais fácil das coisas se Tfor genérico. Aqui está uma possível implementação genérica de um ObjectFactoryfabricante:

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

Refatorei sua versão em algo que o compilador pode verificar principalmente como seguro para tipos, de modo a evitar asserções de tipo . Vamos examinar isso.

A makeFactoryfunção é genérica no tipo de mapeamento de objeto Te leva um argumento denominado DEFAULT_IMPLEMENTATIONSde tipo { [K in keyof T]: () => T[K] }. Este é um tipo mapeado cujas chaves Ksão iguais às de, Tmas cujas propriedades são funções zero-arg que retornam um valor do tipo T[K]. Você pode ver como o seu existente DEFAULT_IMPLEMENTATIONSera assim: cada propriedade era uma função zero-arg retornando um valor da interface correspondente.

Dentro da implementação da função, nós criamos MOCK_IMPLEMENTATIONS. Esta variável tem quase o mesmo tipo, DEFAULT_IMPLEMENTATIONSmas com as propriedades sendo opcionais (conforme afetado pelo modificador de opcionalidade ?em [K in keyof T]?).

A função retorna a própria fábrica, que possui dois métodos:

O getInstancemétodo é genérico em K, o tipo do nome da interface e o valor de retorno é do tipo T[K], a propriedade da interface correspondente. Eu implemento isso mesclando DEFAULT_IMPLEMENTATIONSe MOCK_IMPLEMENTATIONSpor meio de propagação de objeto , e anotando que este compositeInjectableé o mesmo tipo que DEFAULT_IMPLEMENTATIONS. Em seguida, indexamos nele com o interfaceNamee chamamos.

O mockWithInstancemétodo também é genérico em K, o tipo do nome da interface e aceita um parâmetro do tipo K(o nome da interface) e um parâmetro do tipo T[K](a interface correspondente).


Vamos ver em ação:

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

Tudo isso funciona como eu acho que você espera. Fazemos ObjectFactorychamando makeFactorycom o DEFAULT_IMPLEMENTATIONSobjeto desejado . Anotei aqui que a SomeInterfacepropriedade retorna um valor do tipo SomeInterface(caso contrário, o compilador poderia inferir Implementationqual pode ser mais específico do que você gostaria).

Então podemos ver que o compilador nos permite chamar ObjectFactory.getInstance()e ObjectFactory.mockWithInstance()com os argumentos adequados e retornar os tipos esperados, e também funciona em tempo de execução.


Link do Playground para o código