😎

Effect-TSでDIがしたい!

2024/12/15に公開

この記事は株式会社ビットキー Advent Calendar 2024 15日目の記事です。

はじめに

本記事ではEffect(googlabilityが低いためEffect-TSとも表記される。以下Effect-TS)というTypeScriptライブラリを用いてDI(Dependency Injection、依存注入)を行う方法を紹介します。

Effect-TSは2024年3月に安定版であるv3がリリースされました。2024年に開催されたTSKaigiでも話題として取り上げられており、注目を集めています。Githubのstar数も2024年に入り急上昇しています。

私のチームではプロダクトの一部でEffect-TSを採用しています。実際に使ってみて得られた所感含め、Effect-TSを用いたDI実装方法をこの記事で紹介できればと思います。

Effect-TSとは?

Effect is a powerful TypeScript library designed to help developers easily create complex, synchronous, and asynchronous programs.

Introduction

Effect-TSは複雑な同期/非同期処理の実装を簡潔にすることで堅牢なアプリケーションを作ることをサポートするTypeScriptライブラリです。特にエラーハンドリングや依存注入、リトライ機構、Telemetryなど、複雑な実装になりがちな部分が主要なユースケースとして挙げられることが多いです。

特徴としては関数型プログラミングの影響を大きく受けていることです。高階関数や関数結合を用いてプログラム全体を作っていきます。

Effect-TSではEffect が基本単位となります。Effectの基本構造は以下の通りになります。

Effect<Success, Error, Requirements>
  • Success: プログラムの実行に成功したときに返ってくる値の型
  • Error: プログラムの実行に失敗したときに返ってくる値の型
  • Requirements: プログラムの実行時に必要となる依存(の型)

イメージとしてはResult型に近いですが、必要となる依存も型シグネチャ中に表現されるのが特徴です。

Effect-TSの実装例:エラーハンドリング

最もシンプルなEffect-TSのユースケースとして割り算でのエラーハンドリングを考えてみます。

以下がEffect-TSを用いて実装した例です。

const divide = (a: number, b: number): Effect.Effect<number, Error, never> =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b)
const result = Effect.runSync(divide(10, 2)) // 0にすると実行時にここでerrorが発生する
console.log(result) // 5

特筆すべき点は以下です。

1: 型シグネチャから、成功時/失敗時にどのような型が返ってくるのかわかる

比較対象として、Effect-TSを使わずにシンプルな実装を考えてみましょう

const divide = (a: number, b: number): number => {
  if (b === 0) {
    throw new Error("Cannot divide by zero")
  }
  return a / b
}

この場合、型シグネチャからErrorがthrowされることがわかりません。これはエラーハンドリングの漏れにつながる可能性があります。

一方で、Effect-TSを用いることによって、エラーがthrowされる可能性があることが型シグネチャからわかります(throwされない場合はnever型になる)し、加えてどのような依存が注入されているのかも一目瞭然です。

2: 関数を合成する事によって、エラーハンドリングをする回数が減る

上記の例ではあまりわかりにくいですが、以下のような例を考えましょう

const divide = (a: number, b: number): Effect.Effect<number, Error, never> =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b)

// 関数の合成
const program = divide(10,2)
  .pipe(Effect.andThen((num) => divide(num, 1)))
  .pipe(Effect.andThen((num) => divide(num, 5)))

const result = Effect.runSync(program)
console.log(result)

この例ではdivide関数をpipeメソッドでの結合を行い計3回実行しています。が、エラーハンドリングは1回も行っていません。divide関数を逐次実行する場合、例えばResult型を用いるとErrorがthrowされているかどうかを都度チェックし中身を取り出す必要がありますが、Effect-TSを使って関数の合成を行うことによってエラーを関心の外に追いやり、正常導線=関心のあるロジックに集中することができます。

Effect-TSでDIをやる手順

先述した通り、Effect-TSはエラーハンドリングだけでなくDIにも用いることができます。例として、乱数を生成するプログラムを考えてみましょう。

1. serviceを作成する

まずはプログラムが依存する対象(公式ドキュメントではより抽象的な概念としてserviceと呼ばれます)をContext.Tagを使って作成します。

// ランダム値を生成するservice
class Random extends Context.Tag("MyRandomService")<
  Random,
  { readonly next: Effect.Effect<number> }
>() {}

Context.Tagにはユニークな識別子を与える必要があります。

2. serviceをプログラムで用いる

このserviceをプログラム中で用いることによって先ほど定義したserviceのメソッドを用いることができます。

import { Effect, Context, Console } from "effect"

class Random extends Context.Tag("MyRandomService")<
  Random,
  { readonly next: Effect.Effect<number> }
>() {}

// pipeメソッドで関数を合成する
// この時点でのprogramの型は`Effect<void, never, Random>`になっている
const program = pipe(
  Random,
  Effect.andThen((random) => random.next), // nextメソッドを用いる
  Effect.andThen((randomNumber) =>
    Console.log(`random number: ${randomNumber}`)
  )
)

3. serviceの実装をする

serviceのインターフェースしか定義していないので、実際に渡したい依存の具体を実装します。具体の実装をする際は、providerServiceメソッドを用います。

const runnable = program.pipe(
  Effect.provideService(Random, {
    next: Effect.sync(() => Math.random())
  })
);

4. プログラムを実行する

最後にプログラムを実行します。ここではrunPromiseメソッドを用いていますが、同期処理なのでrunSyncメソッドでも問題有りません。

const result = Effect.runPromise(runnable)

型シグネチャからDIを理解する

上記手順を最初に見たとき、私は「なんでこれでDIできているんだ??」「provideServiceってなんなんだ??」「意味わからないな??🤔」と思いました。なので、型シグネチャから各メソッドの役割を確認し、Effect-TSがどのようにしてDIを可能にしているのかをみてみます。

ポイント1:andThenメソッドは何をしているか

andThenメソッドの型シグネチャは以下の通りです

<A, X>(
  f: (a: NoInfer<A>) => X
): <E, R>(
  self: Effect<A, E, R>
) => [X] extends [Effect<infer A1, infer E1, infer R1>] ? Effect<A1, E | E1, R | R1>
  : [X] extends [PromiseLike<infer A1>] ? Effect<A1, E | Cause.UnknownException, R>
  : Effect<X, E, R>

関数を引数にEffectを受け取り、新しいEffectを返す関数になっています。ポイントは返り値のEffectの型がEffect<A1, E | E1, R | R1>になっている(厳密には条件付きなのでなる可能性がある)ことです。依存の型がR | R1に変わっている通り、依存が増えていることが分かります。つまり、andThenを使うたびに必要となる依存が積み上がっていくことがわかります。

ポイント2:provideServiceメソッドは何をしているか

provideServiceメソッドの型シグネチャ(の一部)は以下の通りです

<T extends Context.Tag<any, any>>(
	tag: T,
	service: Context.Tag.Service<T>
): <A, E, R>(self: Effect<A, E, R>) => Effect<A, E, Exclude<R, Context.Tag.Identifier<T>>>

provideService(tag, service)を引数にEffect<A, E, R>を受け取ってEffect<A, E, Exclude<R, Context.Tag.Identifier<T>>>を返す高階関数であることが分かります。

ポイントはExclude<R, Context.Tag.Identifier<T>> の部分です。これは型R(渡したEffectの依存の型)からタグTに対応するサービスの型を除外した型になります。つまり、provideServiceはプログラムから依存を剥がすメソッドであることが分かります。すべての依存が解決されると、、、最終的にプログラムのRequirementsの型はneverになります

ポイント3:runPromiseメソッドは何をしているか

export const runPromise: <A, E>(
  effect: Effect<A, E, never>,
  options?: { readonly signal?: AbortSignal } | undefined
) => Promise<A> = _runtime.unsafeRunPromiseEffect

ポイントはeffect: Effect<A, E, never>の部分です。これはrunPromiseメソッドは依存の型がneverになっているEffectしか引数に取らないこと、つまり依存がすべて解決されないとプログラムの実行ができない、ということを表しています。

まとめ

  • andThenメソッドなど使うたびにプログラムに必要な依存が追加されていく(関数合成の際に依存を引き継ぐ)
  • provideServiceは注入された依存を一個ずつ剥がしていくメソッド
  • provideServiceを使って依存を剥がし、runPromiseメソッド等でプログラムを実行する際に依存がなくなっている(=プログラム全体の依存の型がneverになっている)状態であればプログラムの実行が可能になる

Effect-TSは型レベルで依存の追加・依存の解消を表現していることが分かりました。各メソッドが依存に対してどのような作用を持つのかを把握することでEffect-TSを用いたDIが理解できるようになりました。

Effect-TSでDIをして何が変わった?

Effect-TSを使ったDIの方法を紹介してきましたが、実際にEffect-TSを用いることによって以下のメリットが得られたと感じています。

  1. 関数単位で依存注入を行うことができる

    • TypeScriptでDIを実装する際、クラス単位で依存注入することが多いと思います。Effect-TSでは最小単位であるEffectで依存を定義することができるので、例えば関数単位で依存注入を行うことが出来ます。それによって、テスト時に関心のある最低限の依存のみを渡すことができ、テスト実装が楽になりました。
    • しかし依存を細かく設定することで、依存の定義数が増えていってしまう、というのは実際に実装してみて困ったポイントです。
  2. コアとなる処理に注力しやすくなる

    • Effect-TSではprovideServiceメソッドを使って依存解決するタイミングを制御することができます。プログラムのどのタイミングで依存解決しても問題有りません。また、Effect-TSは遅延評価でrunPromiseメソッド等を使うまで処理自体は実行されません。
    • このことにより、コアとなる処理(ビジネスロジック自体やその関数合成など)を記述した後に依存を解決して結果を得る、というフローでプログラムを作ることができます。そのためより重要な処理に集中しやすくなった、と個人的には感じました。
  3. 単体テストが書きやすくなる

    • Effect-TSを使っているからというより関数型プログラミングによる影響かもしれませんが、依存の分離がしやすいことで単体テストが書きやすくなりました。
    • Effect-TSでは関数の結合でプログラムを作りますが、作られる関数は責務が明確であり純粋であるので、Unitテストがめちゃくちゃ書きやすいと個人的には感じました(CopliotなどのAIツールと相性が良い!)。

まとめ

Effect-TSは関数型ライクなTypeScriptライブラリです。Effect-TSを使うことでDIが簡潔に実装できるようになります。一方で、オブジェクト指向的なやり方とは異なるようなやり方で依存注入を行うので慣れが無いととっつきづらいものであることは確かです。最初触れてみたときは型エラーとなっている部分がわかりにくかったりと非常に苦戦しました。しかし本記事で紹介したように型ジェネリクスから各種メソッドを把握することでEffect-TSのことを少しは理解できるようになると思います。


明日の16日目の株式会社ビットキー Advent Calendar 20244は、Data Platformチームの@bitkey_ryou_ikutaが担当します!

GitHubで編集を提案
Bitkey Developers

Discussion