TypeScriptでこの依存性注入コンテナのジェネリック型およびマップされた型を宣言する方法
私はTypeScriptで、このシンプルな非同期依存性注入コンテナを考案しました:
function Container<P extends { [name: string]: { (c: any): Promise<any> } }>(provider: P) {
const cache: { [name in keyof P]?: Promise<any> } = {};
const container = function(name: keyof P) {
if (!cache[name]) {
cache[name] = provider[name](container);
}
return cache[name];
}
return container;
}
こちらは簡単な使用例です:
class UserCache { }
class UserService {
constructor(private cache: UserCache) { }
}
let container = Container({
"cache": async c => new UserCache(),
"user-service": async c => new UserService(await c("cache"))
});
container("user-service").then(service => {
console.log(service);
});
ここで期待されていることを簡単に説明すると、Container 関数は基本的に「型変換」のようなもので、サービス定義のマップ(provider)を受け取り、ファクトリー関数、つまり container を返します。サービス定義は container インスタンスを受け取るので、自分の依存関係を参照でき、サービスインスタンスに解決される Promise を返します。
型宣言について助けが必要です。
私は name 引数の型チェックまで進みました。結果として得られる container 関数は、provider に存在するキーのみを受け付けます。ここまでは順調です。
欠けているのは、container への呼び出しの汎用的な戻り型です。これにより、await c("cache") のようなネストされたサービスの検索も正しく解決され、型チェックされるようになります。
それをどうやってやるか、何かアイデアはありますか?
回答 #1
問題は、このcontainerの正しい型をコンパイラに推論させることです:
let container = Container({
"cache": async c => new UserCache(),
"user-service": async c => new UserService(await c("cache"))
});
しかし、これは Container に渡される引数の型に依存します。それはオブジェクトリテラルなので、コンパイラはプロパティ初期化子の型を見ます。
async c => new UserCache()
そして、c に型注釈がないことに気づくので、その型は推論される必要があり、制約は container と同じ型であるということです - おっと、TypeScript は循環制約を伴う型推論にうまく対応していません。
`c` は `any` と推論されます。その回避方法はありません:
// let's start with imaginary type P which maps names to provided types
// and declare desired container type
type ContainerFunc<P> = <N extends keyof P>(n: N) => Promise<P[N]>;
// and its argument type
type ProviderType<P> = { [N in keyof P]: (c: ContainerFunc<P>) => Promise<P[N]> };
function Container<P>(provider: ProviderType<P>): ContainerFunc<P> {
const cache: { [N in keyof P]?: Promise<P[N]> } = {};
const container = function<N extends keyof P>(name: N): Promise<P[N]> {
if (!cache[name]) {
cache[name] = provider[name](container);
}
return cache[name];
}
return container;
}
class UserCache { cache: {} }
class UserService {
constructor(private cache: UserCache) { }
}
let container = Container({
"cache": async c => new UserCache(),
"user-service": async c => new UserService(await c("cache"))
});
循環参照を取り除くには、c パラメータを削除する必要があります - これは常に container 自体なので、そのように使用しましょう:
type ContainerFunc<P> = <N extends keyof P>(n: N) => Promise<P[N]>;
type ProviderType<P> = { [N in keyof P]: () => Promise<P[N]> };
function Container<P>(provider: ProviderType<P>): ContainerFunc<P> {
const cache: { [N in keyof P]?: Promise<P[N]> } = {};
const container = function<N extends keyof P>(name: N): Promise<P[N]> {
if (!cache[name]) {
cache[name] = provider[name]();
}
return cache[name];
}
return container;
}
class UserCache { cache: {} }
class UserService {
constructor(private cache: UserCache) { }
}
let container = Container({
"cache": async () => new UserCache(),
"user-service": async () => new UserService(await container("cache"))
});
しかし現在、container の型は any と推論されます。なぜなら "user-service" の初期化子が関数本体内で container を参照しており、そのため関数の返り値の型を推論できなくなっているからです。
戻り値の型注釈を追加する必要があります:
let container = Container({
"cache": async () => new UserCache(),
"user-service": async (): Promise<UserService> => new UserService(await container("cache"))
});
これで動作しています:
let container = Container({
"cache": async () => new UserCache(),
"user-service": async (): Promise<UserService> => new UserService(await container("user-service"))
});
// error: Argument of type 'UserService' is not assignable to parameter of type 'UserCache'.
私はそれがあなたが手に入れられる最高だと信じています。
更新 コメントであなたが提案したように、循環を避けるもう一つの方法は、実装の前にすべてのプロバイダー名と提供される型を事前に明記することです:
type ContainerFunc<P> = <N extends keyof P>(n: N) => Promise<P[N]>;
type ProviderType<P> = { [N in keyof P]: (c: ContainerFunc<P>) => Promise<P[N]> };
function Container<P>(provider: ProviderType<P>): ContainerFunc<P> {
const cache: { [N in keyof P]?: Promise<P[N]> } = {};
const container = function<N extends keyof P>(name: N): Promise<P[N]> {
if (!cache[name]) {
cache[name] = provider[name](container);
}
return cache[name];
}
return container;
}
class UserCache { cache: {} }
class UserService {
constructor(private cache: UserCache) { }
}
interface ProviderTypes {
cache: UserCache;
"user-service": UserService;
}
let container = Container<ProviderTypes>({
"cache": async c => new UserCache(),
"user-service": async c => new UserService(await c("cache"))
});
回答 #2
ご存知の通り @artem が言ったように、コンパイラはオブジェクトリテラルの型が自身を参照する場合、その型を推論することを拒否します(これは完全な制限で、コンパイラもこれを指摘します)。したがって、現在の構造においては、@artem の答えが得られる最良の結果です。
検討できるリファクタリングの一つは、プロバイダーを一度に追加するのではなく、コンテナビルダークラスの add メソッドを使用する方法です。add の返り値の型は、新たに追加された時点までに追加された型を解決できる新しいコンテナビルダーを返すので、新しく追加されたサービスは既に登録されているサービスのみを利用できます(逆境を利用するようなものですが、循環参照を避けられるため、必ずしも悪いことではないかもしれません)。
実装はこのような形になるでしょう:
function Container() {
type ContainerFunc<P> = <N extends keyof P>(n: N) => P[N];
class ContainerBuilder<T extends { [P in keyof T]: Promise<any> }> {
public constructor(private provider: { [P in keyof T]: (c: any) => T[P] } = {} as any) {
}
public add<K extends string, V>(name: K, p: (c: ContainerFunc<T>)=> Promise<V>): ContainerBuilder<T & { [P in K]: Promise<V> }> {
this.provider[name] = p;
return this as any;
}
finish() : ContainerFunc<{ [P in keyof T]: T[P]}> {
const provider = this.provider;
const cache: { [name in keyof T]?: Promise<any> } = {};
const container = function<K extends keyof T>(name: K) : T[K] {
if (!cache[name]) {
cache[name] = provider[name](container);
}
return cache[name] as any;
}
return container as any;
}
}
return new ContainerBuilder()
}
class UserCache { }
class OfficeCache {}
class UserService {
constructor(private cache: UserCache, private officeCache?: OfficeCache) { }
}
var c = Container()
.add("user", async ()=> new UserCache())
// all typed corectly, no any, the comented code below would be an error as office has not been registered yet
.add("user-service", async c=> new UserService(await c("user") /* , await c("office") */))
.add("office", async c=> new OfficeCache())
.finish();
let userService = c("user-service");
