TypeScriptの関数向けDIフレームワークを作った
はじめに
株式会社YOSHINANIに外部技術顧問として参加している、株式会社INFLUのNakano as a Serviceです。
この度TypeScriptでクラスでいうコンストラクタインジェクションによるDIを関数でも実現するためのフレームワーク「injecfn」を作成しました!
この記事では「injecfn」を作成した経緯をコード例を交えて軽く説明し、それを実現しているTypeScriptのテクニックについて詳しく解説します。
経緯
私たちのチームでは、コードの見通しを良くし、テスト容易性を高めるために、関数型プログラミングのスタイルを積極的に取り入れたいと考えていました。
しかし、依存性の注入(DI)という、現代的なアプリケーション開発に不可欠なパターンを実装しようとしたとき、私たちは壁にぶつかりました。
オブジェクト指向のクラスを使えば、DIは非常に直感的です。コンストラクタが依存性を受け取るための自然な「入り口」として機能します。
// クラスなら、コンストラクタで依存性を注入するだけ
class UserService {
constructor(
private db: Database,
// オプショナルな依存性
private logger?: Logger = new DefaultLogger(),
) {}
getUser(userId: string) {
const user = this.db.getUser(userId);
this.logger.log("User fetched");
return user;
}
}
// 依存性を渡してインスタンス化
const userService = new UserService(new MyDatabase(), console);
// 以降はこれを使い回せる
とてもシンプルですね。これは普通に関数を定義するだけでは実現できません。
function getUser(
db: Database, // 必須の依存性
userId: string, // 引数
logger?: Logger = new DefaultLogger() // オプショナルな依存性
) {
const user = db.getUser(userId);
logger.log("User fetched");
return user;
}
// 実行の度に毎回依存性を渡す必要がある。
getUser(new MyDatabase(), "user_abc", console)
getUser(new MyDatabase(), "user_xyz", console)
では、これを「関数」で実現しようとするとどうなるでしょうか?
関数型のアプローチに詳しい開発者であれば、カリー化や高階関数といったテクニックを思い浮かべるかもしれません。
// 関数型のアプローチ(カリー化)
const constructGetUser = (
db: Database, logger: Logger = new DefaultLogger()
) => (userId: string) => {
const user = db.getUser(userId);
logger.log("User fetched");
return user;
};
// 依存性を渡して、最終的な関数を生成
const getUser = constructGetUser(new MyDatabase(), console);
// 以降はこれを使い回せる
getUser("user_abc")
getUser("user_xyz")
この方法は確かに強力ですが、チームの誰もが関数型のパラダイムに精通しているわけではありません。「関数を返す関数」という概念は、時に混乱を招き、依存関係が増えるほど引数の管理も煩雑になります。また、「一部の依存性だけデフォルト値にしたい」といった柔軟な設定も、簡単には実現できませんでした。
「クラスの手軽さ」と「関数のシンプルさ」。この両方の良いところを享受できる方法はないだろうか?
関数であっても、まるでクラスのコンストラクタのように直感的に依存性を定義し、型安全性を一切犠牲にすることなく、快適にDIを行いたい──。
その思いから生まれたのが、今回ご紹介するinjecfn
です。injecfn
を使うと、先ほどの関数は次のように書くことができます。
const constructGetUser = defineFn({
db: required<Database>(), // dbは必須
logger: console, // loggerにはデフォルト値を設定
}, ({db, logger}, userId: string) => {
const user = db.getUser(userId);
logger.log("User fetched");
return user;
});
// 必須の依存性 `db` だけを渡して、関数を組み立てる
const getUser = constructGetUser({
db: new MyDatabase(),
});
// オプショナルな依存性 `logger` をモックで上書きする
const getUser = constructGetUser({
db: new MyDatabase(),
logger: new MockLogger()
});
この一見シンプルなAPIの裏側には、TypeScriptの強力な型システムが隠されています。
この記事では、その仕組みを一つずつ解説していきます。
required<T>()
: 必須依存性のマーカー
1. まず注目すべきは required<T>()
関数です。この関数は、実行時には requiredSymbol
という symbol
値を返すだけのシンプルなものです。
export const requiredSymbol = Symbol("required");
export function required<T>(): Required<T> {
return requiredSymbol as Required<T>;
}
しかし、その戻り値の型 Required<T>
が重要です。
type Required<T> = typeof requiredSymbol & {
_type: T;
};
これは「ブランド型(Branded Type)」や「幽霊型(Phantom Type)」と呼ばれるテクニックの一種です。required<Database>()
のように呼び出されたとき、戻り値の型は symbol & { _type: Database }
となります。
実行時の値はただの symbol
ですが、型システム上では Database
という情報が _type
プロパティに紐付けられています。これにより、defineFn
は「この依存性は必須であり、その型は Database
である」ということをコンパイル時に知ることができます。
Dependencies<T>
: 依存性の型を解決する
2. 次に、defineFn
の中で実際に使われる依存性の型を見てみましょう。defineFn
の第二引数に渡すファクトリ関数 f
の第一引数 deps
の型は Dependencies<T>
となっています。
export type Dependencies<T extends Record<string, unknown>> = {
readonly [K in keyof T]: T[K] extends Required<infer U> ? U : T[K];
};
これは T
(依存定義オブジェクト)の各プロパティを調べて、型を変換するユーティリティ型です。
-
T[K] extends Required<infer U>
: もしプロパティの型がRequired<T>
(例:symbol & { _type: Database }
)にマッチするなら... -
? U
: その_type
から型U
(例:Database
)をinfer
(推論)して、プロパティの型をU
にします。 -
: T[K]
: そうでなければ、元の型(例:console
の型)をそのまま使います。
これにより、defineFn
の実装関数内では、deps.db
の型は Required<Database>
ではなく、実際の Database
型として扱え、deps.logger
はデフォルト値の型 Console
として扱えます。
Requirements<T>
: 「何を渡すべきか」を定義する型
3. このライブラリの最も巧妙な部分が Requirements<T>
型です。これは、defineFn
が返すコンストラクタ関数に渡すべき引数の型を定義します。
export type Requirements<T extends Record<string, unknown>> =
& {
// 必須の依存性
[K in keyof T as T[K] extends Required<unknown> ? K : never]: T[K] extends
Required<infer U> ? U : never;
}
& {
// オプショナルな依存性(デフォルト値を持つもの)
[K in keyof T as T[K] extends Required<unknown> ? never : K]?: T[K];
};
この型は、交差型(&
)を使って2つのオブジェクト型を合成しています。
必須の依存性
一つ目のオブジェクト型は、required<T>()
でマークされたプロパティだけを抜き出し、必須のプロパティとして定義します。as
を使ったキーのリマッピング(Key Remapping)により、Required<T>
型を持つプロパティのキーだけが選別されます。値の型は Dependencies<T>
と同様に Required<U>
から U
を抽出したものです。
結果として、{ db: required<Database>(), logger: console }
という定義からは { db: Database }
という型が生成されます。
オプショナルな依存性
二つ目のオブジェクト型は、required<T>()
でマークされていないプロパティ(つまりデフォルト値を持つプロパティ)を抜き出し、オプショナルなプロパティ(?
)として定義します。
結果として、同じ定義から { logger?: Console }
という型が生成されます。
この2つを &
で結合することで、{ db: Database; logger?: Console }
という、まさに「コンストラクタに渡すべき引数」の型が完成するのです。
HasRequirements<R>
と FnConstructor
: 引数の要不要を切り替える
4. 最後の仕上げです。もし必須の依存性が一つもなければ、コンストラクタの引数は不要(myFnConstructor()
のように呼び出せるべき)です。もし一つでもあれば、引数は必須(myFnConstructor({ ... })
)でなければなりません。
これを実現するのが HasRequirements<R>
です。
type HasRequirements<
R extends Record<string, unknown>,
> = Extract<R[keyof R], Required<unknown>> extends never ? false : true;
この型は、依存定義オブジェクトの型の中に Required<T>
が含まれるかをチェックし、true
または false
のリテラル型を返します。この型レベルの真偽値を使って、FnConstructor
の引数の型を条件付きで切り替えます。
export interface FnConstructor<
T extends Record<string, unknown>,
Fn extends (...args: never[]) => unknown,
> {
(
...args: HasRequirements<T> extends true ? [requirements: Requirements<T>]
: [requirements?: Requirements<T>]
): Fn;
}
-
HasRequirements<T>
がtrue
なら、引数の型は[requirements: Requirements<T>]
となり、引数は必須になります。 -
false
なら、引数の型は[requirements?: Requirements<T>]
となり、引数はオプショナルになります。
これにより、必須の依存関係の有無に応じて、コンストラクタの呼び出し方が静的に変化し、コンパイル時に正しい使い方を強制できます。
defineFn
の実装
驚くべきことに、これら複雑な型定義を支える defineFn
の実行時実装は非常にシンプルです。
export function defineFn<
// ...
>(
dependencies: T,
f: (deps: Dependencies<T>, ...args: Args) => Return,
): FnConstructor<T, (...args: Args) => Return> {
return ((requirements: Requirements<T>) => {
return f.bind(
null,
{ ...dependencies, ...requirements } as Dependencies<T>,
);
}) as FnConstructor<T, (...args: Args) => Return>;
}
返されるコンストラクタ関数は、引数で受け取った requirements
(必須依存性や上書き)と、定義時に与えられた dependencies
(デフォルト値)をマージし、f.bind()
を使って f
の第一引数に束縛(部分適用)します。これにより、依存性が注入済みの新しい関数が生成されるのです。
まとめ
injecfn
は、TypeScriptの高度な型機能(ジェネリクス、条件付き型、キーのリマッピング、幽霊型など)を組み合わせることで、以下のような理想的なDI体験を実現しています。
- 型安全性: 依存関係とその型はコンパイル時に完全にチェックされます。
-
直感的なAPI:
required<T>()
とデフォルト値で、依存性の要件を明確に表現できます。 - 優れたDX: 必須の依存性がある場合のみ引数を要求するなど、エディタ(IDE)の補完や型エラーが開発者を正しく導きます。
-
実行時オーバーヘッドの低減: 実行時のロジックは
bind
による部分適用が中心で、DIコンテナのような複雑な機構は持ちません。
このような体験が実現できるのは、型システムが非常に表現力豊かであり、単なる型チェックツールに留まらない強力なメタプログラミングの機能を提供しているTypeScriptのおかげです。
Discussion