Reputation: 2103
I have created the following object factory to instantiate implementations of all my 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");
As you can see, this Factory lets you instantiation and mocking of those interfaces. The main problem is that I have to call this function with the name of the interface AND the interface to keep the type in the assignments:
ObjectFactory.getInstance<SomeInterface>("SomeInterface")
I have seen this question, but I do not like the idea of using a Base
interface. Moreover, that approach does not keep the type neither.
Ideally, I would want to use my approach but without having to use the interface, that is, use only the name of the interface.
Side note: the declaration of Injectable
is a hack to make that code work, ideally, I would like to be able to use only the implementation name, that is:
let DEFAULT_IMPLEMENTATIONS = {
SomeInterface: Implementation
}
Upvotes: 1
Views: 340
Reputation: 327859
Since you have a fixed list of name-to-type mappings you care about supporting, the general approach here is to think in terms of the object type T
representing this mapping, and then for any supported interface name K extends keyof T
, you will be dealing with functions that return the property at that name... namely, functions of type () => T[K]
. Another way to say this is that we will be using keyof
and lookup types to help give types to your factory.
We will only be using a concrete type like {"SomeInterface": SomeInterface; "Date": Date}
for T
, but in what follows the compiler has an easier time of things if T
is generic. Here's a possible generic implementation of an ObjectFactory
maker:
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;
}
}
}
I refactored your version into something the compiler can mostly verify as type safe, so as to avoid type assertions. Let's walk through it.
The makeFactory
function is generic in the object mapping type T
, and takes an argument named DEFAULT_IMPLEMENTATIONS
of type { [K in keyof T]: () => T[K] }
. This is a mapped type whose keys K
are the same as those of T
but whose properties are zero-arg functions that return a value of type T[K]
. You can see how your existing DEFAULT_IMPLEMENTATIONS
was like this: each property was a zero-arg function returning a value of the corresponding interface.
Inside the function implementation, we create MOCK_IMPLEMENTATIONS
. This variable has almost the same type as DEFAULT_IMPLEMENTATIONS
but with the properties being optional (as effected by the optionality modifier ?
in [K in keyof T]?
).
The function returns the factory itself, which has two methods:
The getInstance
method is generic in K
, the type of the interface name, and the return value is of type T[K]
, the corresponding interface property. I implement this by merging DEFAULT_IMPLEMENTATIONS
and MOCK_IMPLEMENTATIONS
via object spread, and annotating that this compositeInjectable
is the same type as DEFAULT_IMPLEMENTATIONS
. Then we index into it with the interfaceName
and call it.
The mockWithInstance
method is also generic in K
, the type of the interface name, and accepts a parameter of type K
(the interface name), and a parameter of type T[K]
(the corresponding interface).
Let's see it in 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
This all works as I think you expect. We make ObjectFactory
by calling makeFactory
with the desired DEFAULT_IMPLEMENTATIONS
object. Here I have annotated that the SomeInterface
property returns a value of type SomeInterface
(otherwise the compiler would infer Implementation
which may be more specific than you'd like).
Then we can see that the compiler lets us call ObjectFactory.getInstance()
and ObjectFactory.mockWithInstance()
with the proper arguments and returning the expected types, and it also works at runtime.
Upvotes: 2