TypeScript で Cake Pattern

TypeScript で Cake Pattern っぽい DI (依存性注入) をするためのライブラリを作ったので, そのご紹介です.

この記事での解説や他の手法との比較は前回の記事を前提とするので, まずはこちらをお読みください.

Scala における Cake Pattern

Cake Pattern は Scala で DI を実現する方法の一つで, ライブラリやアノテーションを使わず Scala の言語機能のみで完結するシンプルさが特徴です.

以下の例では前回の記事から引き続き, 時刻と乱数を扱うコンポーネントと, それらに依存したコンポーネントがある, というものを使います.

まずはコンポーネント ClockComponent, RandomComponent, MyServiceComponent を定義します. Scala の trait は TypeScript でいうと interface か abstract class のようなものだと思ってください.

trait Clock {
  def getTime(): Long
}
trait ClockComponent {
  val clock: Clock
}

trait Random {
  def getRandom(): Double
}
trait RandomComponent {
  val random: Random
}

trait MyService {
  def getTimeAndRandom(): (Long, Double)
}
trait MyServiceComponent {
  val myService: MyService
}

続いてコンポーネントに対する実装を書きます. ClockComponentRandomComponent に対する実装はやるだけ.

trait ClockImpl extends ClockComponent {
  val clock = new Clock {
    import com.github.nscala_time.time.Imports._

    def getTime(): Long = DateTime.now().getMillis()
  }
}

trait RandomImpl extends RandomComponent {
  val random = new Random {
    private val rand = new scala.util.Random

    def getRandom(): Double = rand.nextDouble()
  }
}

MyServiceComponent に対する実装では ,self: ClockComponent with RandomComponent => のように自分型アノテーションで依存するコンポーネントを記述します. まるで trait が関数で this が引数みたいなアノテーションですね. 実際関数みたいなものだと思います.

trait MyServiceImpl extends MyServiceComponent {
  this: ClockComponent with RandomComponent =>

  val myService = new MyService {
    def getTimeAndRandom(): (Long, Double) = (clock.getTime(), random.getRandom())
  }
}

このように自分型アノテーションを書いておくと, 実装の中で ClockComponentRandomComponent のメンバー clockrandom を利用でき, またインスタンス化する際には ClockComponentRandomComponent の実装が存在していることが要求されるようになります.

肝心のインスタンス化は以下のように, with を使って実装をくっつけ (mixin) ていきます. ここでは MyServiceImpl の自分型アノテーションの通りに ClockComponentRandomComponent を実装した ClockImplRandomImpl を mixin していますが, もしこれらが足りなければコンパイルエラーになります.

val app = new MyServiceImpl with ClockImpl with RandomImpl
println(app.myService.getTimeAndRandom()) // => [<time>, <random>]

この方法は型安全かつ依存関係も明確に表され, Constructor Injection のようにインスタンス化するときに順序や引数を気にしたりする必要もないという, なかなか優れたものです.

TypeScript で Cake Pattern を再現する

さて TypeScript で同じことをやろうとすると trait がないので, そこをライブラリで補ってやる必要があります. ということで作ったのが今回ご紹介するこちら.

まずは Scala の場合と似た感じでコンポーネントを定義します.

import type { Component } from "@susisu/hokemi";

type Clock = {
  getTime: () => number;
};
type ClockComponent = Component<"clock", Clock>;

type Random = {
  getRandom: () => number;
};
type RandomComponent = Component<"random", Random>;

type MyService = {
  getTimeAndRandom: () => [number, number];
};
type MyServiceComponent = Component<"myService", MyService>;

Component の定義を抜粋すると以下の通りで, 特に難しいことはしていません.

export type Component<N extends string, T extends unknown> = {
  __type: "hokemi.type.Component";
  name: N;
  type: T;
};

続いて実装です. こちらも Scala の場合と同様にやるだけで, trait の代わりに普通の関数を使います.

import { impl } from "@susisu/hokemi";

const clockImpl = impl<ClockComponent>("clock", () => ({
  getTime: () => Date.now(),
}));

const randomImpl = impl<RandomComponent>("random", () => ({
  getRandom: () => Math.random(),
}));

自分型アノテーションがあった部分は関数の引数になります.

const myServiceImpl = impl<MyServiceComponent, [ClockComponent, RandomComponent]>(
  "myService",
  ({ clock, random }) => ({
    getTimeAndRandom: () => [clock.getTime(), random.getRandom()],
  })
);

impl の定義も抜粋すると以下の通りで, 型はさておき値は見たままでなんら複雑なことはないです.

export function impl<C extends AbstractComponent, Ds extends AbstractComponent[] = []>(
  ...[name, factory]: ImplArgs<C, Ds>
): Impl<C, Ds> {
  const provider: AbstractProvider = {
    __type: "hokemi.type.Provider",
    name,
    factory,
  };
  return provider as Impl<C, Ds>;
}

最後にこれらを mixer でくっつけてインスタンス化します.

import { mixer } from "@susisu/hokemi";

const app = mixer(myServiceImpl, clockImpl, randomImpl).new();
console.log(app.myService.getTimeAndRandom()); // => [<time>, <random>]

もし実装が足りていなかったり, 間違った型の実装が与えられた場合は, インスタンス化しようとしたときにコンパイルエラーになります.

const app = mixer(myServiceImpl, clockImpl).new();
//                                          ~~~
// TS2349: This expression is not callable.
//   Type '{ [missingDependenciesError]: { reason: "some dependencies are missing"; providerName: "myService"; dependencies: [{ name: "random"; expectedType: Random; }]; }; }' has no call signatures.

ということで Scala の Cake Pattern と同様に, 型安全かつ依存関係も明確に表され, Constructor Injection のようにインスタンス化するときに順序や引数を気にしたりする必要もないというものができました.

このライブラリの一番難しい部分は mixer の実装ですが, 型はさておき実行時のコードは高々 40 行程度です (そしてたぶん型は読めなくても困らないです). Scala とは異なり言語機能だけで完結とまではいきませんが, 十分にシンプルな部類と言えるでしょう.

まとめ