Minimal Cake Pattern (a.k.a. Mix-In Injection) はミックスインを使った依存性注入の手法で、その詳細は Minimal Cake Pattern 再考 で解説されています。Minimal Cake Pattern は Scala の記述力を生かしたデザインパターンであり、他の言語への移植は必ずしも容易ではありませんが、Kotlin と Swift に関しては以下の方法が知られています。
- Kotlin: KotlinでもMinimal Cake Pattern
- Swift: SwiftでもMinimal Cake Pattern
この記事では、TypeScript での Minimal Cake Pattern の実装を提案します。
実装
TypeScript で Minimal Cake Pattern を行うにあたって、最も大きな障害は多重継承ができないことです。Minimal Cake Pattern は依存関係の宣言と依存先の注入をミックスインによって実現するので、多重継承を前提としています。
それでも TypeScript でミックスインを行う方法は従来から知られています。多重継承を模倣するために、一直線の継承関係を簡単に書く方法を導入します。
まずはコードを見てみましょう。
abstract class UserService extends compose(
UsesUserRepository,
UsesGroupRepository
) {
explain = (): string => {
const user = this.userRepository.getUser();
const group = this.groupRepository.getGroup();
return `User: ${user.name}, Group: ${group.name}`;
}
}
function UsesUserService<TBase extends AbstractConstructor>(Base: TBase) {
abstract class Inner extends Base {
abstract userService: UserService;
}
return Inner;
}
function MixInUserService<TBase extends AbstractConstructor>(Base: TBase) {
class UserServiceInjected extends Inject(
UserService,
MixInUserRepository,
MixInGroupRepository
) {};
abstract class Inner extends Base {
userService = new UserServiceInjected();
};
return Inner;
}
ただし、共通ライブラリとして以下の型・関数を使います。
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
abstract class AbstractBase {}
type MixInFn<TBase extends AbstractConstructor, TNew extends AbstractConstructor> = (Base: TBase) => TNew;
const identity = <T>(x: T): T => x;
export const compose = <
T0 extends AbstractConstructor,
T1 extends AbstractConstructor = T0,
T2 extends AbstractConstructor = T1,
T3 extends AbstractConstructor = T2,
T4 extends AbstractConstructor = T3,
T5 extends AbstractConstructor = T4,
T6 extends AbstractConstructor = T5,
T7 extends AbstractConstructor = T6,
T8 extends AbstractConstructor = T7,
T9 extends AbstractConstructor = T8,
T10 extends AbstractConstructor = T9
>(
f0: MixInFn<T0, T1> = identity as MixInFn<T0, T1>,
f1: MixInFn<T1, T2> = identity as MixInFn<T1, T2>,
f2: MixInFn<T2, T3> = identity as MixInFn<T2, T3>,
f3: MixInFn<T3, T4> = identity as MixInFn<T3, T4>,
f4: MixInFn<T4, T5> = identity as MixInFn<T4, T5>,
f5: MixInFn<T5, T6> = identity as MixInFn<T5, T6>,
f6: MixInFn<T6, T7> = identity as MixInFn<T6, T7>,
f7: MixInFn<T7, T8> = identity as MixInFn<T7, T8>,
f8: MixInFn<T8, T9> = identity as MixInFn<T8, T9>,
f9: MixInFn<T9, T10> = identity as MixInFn<T9, T10>,
): T10 => Inject(AbstractBase as T0, f0, f1, f2, f3, f4, f5, f6, f7, f8, f9);
export const Inject = <
T0 extends AbstractConstructor,
T1 extends AbstractConstructor = T0,
T2 extends AbstractConstructor = T1,
T3 extends AbstractConstructor = T2,
T4 extends AbstractConstructor = T3,
T5 extends AbstractConstructor = T4,
T6 extends AbstractConstructor = T5,
T7 extends AbstractConstructor = T6,
T8 extends AbstractConstructor = T7,
T9 extends AbstractConstructor = T8,
T10 extends AbstractConstructor = T9
>(
base: T0,
f0: MixInFn<T0, T1> = identity as MixInFn<T0, T1>,
f1: MixInFn<T1, T2> = identity as MixInFn<T1, T2>,
f2: MixInFn<T2, T3> = identity as MixInFn<T2, T3>,
f3: MixInFn<T3, T4> = identity as MixInFn<T3, T4>,
f4: MixInFn<T4, T5> = identity as MixInFn<T4, T5>,
f5: MixInFn<T5, T6> = identity as MixInFn<T5, T6>,
f6: MixInFn<T6, T7> = identity as MixInFn<T6, T7>,
f7: MixInFn<T7, T8> = identity as MixInFn<T7, T8>,
f8: MixInFn<T8, T9> = identity as MixInFn<T8, T9>,
f9: MixInFn<T9, T10> = identity as MixInFn<T9, T10>,
): T10 => f9(f8(f7(f6(f5(f4(f3(f2(f1(f0(base))))))))));
挙動の解説
まず TypeScript のクラスの仕様について知る必要があります。 class YourClass { ... }
とクラスを定義したとき、 YourClass
はほとんどの言語においては型の名前ですが、TypeScript では型の名前であり、 コンストラクタ関数 の名前でもあります。したがって、以下の書き方が合法になります。
class YourClass {}
const ctor = YourClass; // コンストラクタ関数の代入
const instance = new ctor(); // コンストラクタ関数の呼び出し(YourClass 型のインスタンスが作られる)
class SubClass extends ctor {} // コンストラクタに対して継承できる
// コンストラクタ関数の型は new(...args: any[]) => T と決まっている(言語仕様)
type Constructor<T = object> = new (...args: any[]) => T;
// 同様に、抽象クラスのコンストラクタ関数の型も決まっている
type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
この仕様を使って、「クラス(コンストラクタ関数)を受取り、そのクラスに { userRepository: UserRepository }
を持たせた子クラス(のコンストラクタ関数)を返す関数」を定義できます。
function UsesUserRepository<TBase extends AbstractConstructor>(Base: TBase) {
abstract class Inner extends Base {
abstract userRepository: UserRepository;
}
return Inner;
}
TypeScript は構造的部分型をサポートしているので、この関数の返り値の型は TBase & { abstract userRepository: UserRepository }
のような形になっています。したがって、返されたクラスを継承したクラスは userRepository
フィールドを持っています。
同様の Uses クラスを用意すると、 class UserService extends UsesUserRespository(UsesGroupRepository(AbstractBase)) {}
のようにして順々に継承できます。ここで、ダミーの空クラス AbstractBase
を用意してそこに userRepository
と groupRepository
を生やしました。
しかしこれではネストが鬱陶しいので、関数の合成をする関数を定義します。
abstract class AbstractBase {}
const identity = <T>(x: T): T => x;
export const compose = <
T0 extends AbstractConstructor,
T1 extends AbstractConstructor = T0,
T2 extends AbstractConstructor = T1,
T3 extends AbstractConstructor = T2,
>(
f0: MixInFn<T0, T1> = identity as MixInFn<T0, T1>,
f1: MixInFn<T1, T2> = identity as MixInFn<T1, T2>,
f2: MixInFn<T2, T3> = identity as MixInFn<T2, T3>,
): T3 => f2(f1(f0(AbstractBase as T0)));
これで abstract class UserService extends compose(UsesUserService, UsesGroupService)
と書けるようになりました。デフォルト値を適切に設定しているので、引数が 1, 2, 3 個のいずれでも動きます。同じ要領で引数の個数を増やすこともできます。ただし、 f0, f1, f2
を一つの配列にまとめることはできません。構造的部分型を使っているため、 T0, T1, T2, T3
はそれぞれ別個の型パラメータでなければならないからです。
同様にして、MixIn 関数群も合成できます。依存先が注入された UserService
を作るには、 UserService
と compose(MixInUserService, MixInGroupService)
の両方を継承すればよいです。 compose(MixInUserService, MixInGroupService)
は MixInGroupService(MixInUserService(AbstractBase))
だったことを思い出すと、MixInGroupService(MixInUserService(UserService))
を作れればよいです。 AbstractBase
の代わりに任意のクラスを注入できる合成関数 Inject
を新しく定義しましょう。実装クラスに MixIn 関数群を適用すると、依存性注入は完了です。
function MixInUserService<TBase extends AbstractConstructor>(Base: TBase) {
class UserServiceInjected extends Inject(
UserService,
MixInUserRepository,
MixInGroupRepository
) {};
abstract class Inner extends Base {
userService = new UserServiceInjected();
};
return Inner;
}
結局、このような継承関係が生まれることになります。
compose(UserService, MixInUserService, MixInGroupService)
とは書けないことに注意してください。 UserService
はコンストラクタ関数、 MixInUserService, MixInGroupService
は「コンストラクタ関数を受け取ってコンストラクタ関数を返す関数」です。
評価
動作するとはいえ、Scala の mix-in injection に比べていくつかの欠点があります。
-
compose, Inject
などの共通ライブラリが必要になる- 言語仕様さえ知っていれば自明に挙動を把握できるのが mix-in injection のメリットだったが、ごく軽量とはいえ別関数の実装を見なければ挙動を追えなくなっている
- 型パズルも難しく、挙動を理解するのは大変
- 依存先 10 個までしか対応できない
- よほどのことがなければ 10 個で十分だし、足りなくなったら増やせばいいので大した問題ではない
- Uses, MixIn 関数の記述が冗長
- VSCode ならスニペットを使えばタイピングは一瞬で終わるので大した問題ではない
こうしてみると1点目がやはり厳しいですね。純粋に言語仕様だけで実現できていた Scala と比べると、ちょっと泥臭さがあります。ライブラリ部分が複雑になればなるほど DI コンテナに対する優位点が薄れていくので、ここが一番のネックになるでしょう。
個人的にはこの程度であれば十分許容範囲かなと思います。Minimal Cake Pattern のメリットとしては静的に DI できることもあるので、それを含めればまだ DI コンテナより好ましいです。
もっときれいに書けるぜという人がいましたら、提案お待ちしています。