「Effect」ってなんだろう?
この記事はなに?
TypeScriptのライブラリの1つである Effect
の導入してみて得られた知見と所感をもとに、 Effect
の概要に関して内容をまとめたものです。
実際に導入を進めるにあたり公式ドキュメントを参照しましたが、既存の Result
型と思想が異なる部分があり、すんなり理解できなかった箇所がありました…。他の Result
型と比較して、何が異なるのか / なぜ異なるのか / 何を解決しようとしているのか を自身の所感も添えてまとめています。
今後 Effect
の導入を考えている開発者が少しでも、 Effect
がどんな特徴を持ちどのような思想に基づいたものなのかの理解の助けになれば幸いです。
※ Result型については以下参照
Effcetとは?
Effect
は開発者が複雑なエラーや非同期処理をより安全に開発できるようにすることを目的とした TypeScript ライブラリです。2024/04 に安定版の v3 がリリースされています。
TSKaigi2024の発表の中でも題材として取り上げられている関数型のライブラリです。
Effect System という概念を取り入れており、Scala や Haskell といった関数型プログラミング言語に影響を受けて作成されています。エラー制御だけでなく、DI や Telemetry 、リソース管理、状態管理 など幅広い機能を提供しています。
Concept
Effect
は複雑なアプリケーションの実装を簡潔に実現することを可能にすることを目的とし、これを実現するために数多くの機能を提供しています。
Effect
を利用することで簡潔に実装できるか…を、公式のトップページにわかりやすく表現されています。
1. エラー制御の対応を入れた場合
←(左) が Effect
を利用せずに実装した場合
→(右) が Effect
を利用した場合
背景に色がついている箇所が、同等の内容の実装箇所となっています。総じて Effect
での実装が簡潔であることが示されています。
2. Retryの制御を入れた場合
3. タイムアウトの制御を入れた場合
4. Traceの仕組みを入れた場合
Effect
を利用せずに実装した場合 (Without Effect
) の事例はやや極端…といより実際には共通化して切り出したらここまで行数かからないよね…とは思うものの Effect
の有用性として、「複雑なアプリケーションの実装を簡潔に実現する」という可能性はなんとなく感じ取れるかと思います。
Function List
Effect
では非常に多く機能を備えています。公式ドキュメントでその片鱗を確認することができます。以下は、公式ドキュメントに記載されているメニューの一覧を以下に記載します。
- Error Management
Effect
でのエラーの概念とその取り扱い方法について - Requirements Management
依存関係をEffect
でどのように取り扱い、解決するかについて - Resource Management
データベースなどの外部リソースとの連携方法について。リソースの解放や外部連携時のロールバックの仕組みについて - Observability
ログやテレメトリについて - Configuration
環境変数やシークレットの管理、取り扱いについて - Runtime
実行環境の定義や管理、活用方法について - Scheduling
リトライやタイムアウトの指定方法について - State Management
状態管理や参照アクセスを許容する変数の取り扱いについて - Batching
一括実行について - Caching
アプリケーションのパフォーマンスを最適化するためのキャッシュ機能について - Concurrency
並行処理の概念と操作方法について - Streaming
有限リストの処理から無限シーケンスの処理について - Testing
テストの実行方法について - Control Flow
制御フローを管理するためのさまざまな関数とその活用方法について。when
、if
、forEach
などについて - Code Style
実装スタイルについて - Other
その他のトピック。データ型、配列、冪等性、順序の管理などについて
公式のDocに記載されているメニュー一覧と簡単な説明ですが、とにかく多い…!
もちろん、これらの機能のすべてを活用し切る必要はありません。この中の一部の機能を活用することで改善することが可能かと思います。
Effectの構造
Effect
は以下で表現されます
Effect<A, E, R>
※ Result
型は Result<A, E>
。Effect
ではR
が追加されている。
A: (Success) Effect
が成功した場合に得られる値の型です。never の場合 Effect
は永遠に実行される(または失敗するまで)ことを意味します。
E: (Error) 実行時に発生可能なエラーの型です。never の場合 Effect
は失敗することがないことを意味します。
R: (Requirements) Effect
の実行に必要なコンテキストデータ (実行に必要な要求、依存) を表します。never の場合 Effect
には依存がなくそのまま実行可能であることを意味します。
A,Eに関しては Result
型と同等ですが、Rは Effect
固有です。Result
型と比較すると、 Effect
はRを余分に管理できるようになっています。
Effect vs Result
実際の利用例を見てみます。
Effect
は多くの機能を提供していますが、その中でもエラー制御に関する機能に関して他のライブラリで提供されている機能との差分を見ていきます。
Effect
で提供されているエラー制御の機能は Result
と近しいですが微妙に異なっています。
ここでは、Result
型のサンプルとして、 neverthrow
のライブラリをここでは取り上げて比較します。
Result の場合
-
関数の定義
import {err, ok, Result} from 'neverthrow'; const divide = (a: number, b: number): Result<number, Error> => { if (b === 0) { return err(new Error('Cannot divide by zero')); } return ok(a / b); };
成功の場合には
ok
で包んで返却し、失敗の場合にはerr
で包んで返却しています。
divide関数のレスポンスはResult<number, Error>
となり、成功であれば number / 失敗であれば Error が返却されることが示されています。 -
処理結果の確認
const result = divide(16, 2); // ^^^^^^ Result<number, Error> if (result.isOk()) { const value = result.value; // ^^^^^ number } if (result.isErr()) { const error = result.error; // ^^^^^ Error }
関数(divide)に引数を与えると
Result<number, Error>
として結果が得られます。
Result型の状態では成功か失敗かわからないので、isOk
/isErr
の分岐をしたうえで中の値を取り出します。
error
は型推論がされるので、利用する側はどんなエラーが発生するかを理解した上でかつエラー時の分岐や処理を記載することが強制されるのでより堅牢な実装を実現することができます。 -
処理の結合
Result型を都度チェックして後続の処理をするのは煩雑となるので…解消するための手段が用意されています。-
都度チェックした場合…
const result1 = divide(16, 2); if (result1.isErr()) { const error1 = result1.error; // ^^^^^ Error throw error1 } const result2 = divide(result1.value, 2); if (result2.isErr()) { const error2 = result2.error; // ^^^^^ Error throw error2 } const result3 = divide(result1.value, 2); if (result3.isErr()) { const error3 = result3.error; // ^^^^^ Error throw error3 } if (result3.isOk()) { const value3 = result3.value; // ^^^^^ number console.log(value3); }
-
Result
型に対してメソッドチェーンで結合する場合const result = divide(16, 2) // ^^^^^^ Result<number, Error> .andThen(result => divide(result, 2)) .andThen(result => divide(result, 2)) .andThen(result => divide(result, 2)); if (result.isOk()) { const value = result.value; // ^^^^^ number console.log(value); } if (result.isErr()) { const error = result.error; // ^^^^^ Error console.log(error); }
都度成否を確認して値を取り出さずとも処理を結合して
Result
型のまま処理を継続し、最後に値を抽出することができます。 -
flow
やpipe
などを用いて関数自体の合成する場合const divide = (b: number) => (a: number): Result<number, Error> => b === 0 ? err(new Error('Cannot divide by zero')) : ok(a / b); const combinedFunction = flow( divide(2), Rusult.andThen(divide(2)), Rusult.andThen(divide(2)) ); const result = combinedFunction(16); // ^^^^^^ Result<number, Error> if (result.isOk()) { const value = result.value; // ^^^^^ number console.log(value); } if (result.isErr()) { const error = result.error; // ^^^^^ Error console.log(error); }
-
Effect を使った場合
-
関数の定義
import { Effect } from "effect" const divide = (a: number, b: number): Effect.Effect<number, Error> => { if (b === 0) { return Effect.fail(new Error('Cannot divide by zero')); } return Effect.succeed(a / b); };
Result
型と同様の形式で、成功時はEffect.succeed
失敗時はEffect.fail
で包んで返却します。 -
処理結果の確認
const program = divide(16,2); // ^^^^^^^ Effect<number, Error> const value = Effect.runSync(program); // throw error // ^^^^^ number
関数の定義とは対照的に、処理結果は特に以下2点において異なります。
- 関数に引数を渡しただけでは、結果を得ることはできません。
Effect.runSync
を実施することで結果を得ることができます。 -
Effect.runSync
を実施した場合、成功時の値がそのまま取得され、失敗時はthrowされます。 ※ 関数に引数を渡した時点では処理が実行されることは担保されず、Effect.runSync
を実施時に処理が実施される点においてもResult
型と異なります。
結果の確認方法として、Errorをthrowさせないような記述の仕方も可能ですが、
Result
型と比較して使い方がやや煩雑で、公式ドキュメントでもあまり利用されておらず、後述の理由もあり、推奨はされていないと考えています。const program = divide(16,2); // ^^^^^^^ Effect<number, Error> const exit = Effect.runSyncExit(program); // ^^^^ Exit<number, Error> if(Exit.isSuccess(exit)){ const value = exit.value; // ^^^^^ number console.log(value) } if (Exit.isFailure(exit)) { if (Cause.isFailType(exit.cause)) { const error = exit.cause.error; // ^^^^^ Error console.log(error); } }
- 関数に引数を渡しただけでは、結果を得ることはできません。
Effect の特異性
上の例で見たように、特に以下の点において Effect
は Result
と異なります。
- 関数に引数を渡して
porgram
を作成する(=Build up
)、処理の実行のRun
の2ステップに分けられます -
Run
をした際には、成功時の値が返却され失敗時の値は Throw されます - なんか
R
(=Requirement) が追加されている
上記の特異性で何を 解決できるのか / 解決しようとしているのか を考えてみます。
Build up / Run ステップの分離
先で見たとおり、Result型とは異なり以下の2つのステップが必要となります。
- 引数を渡して
program
を作成する (=Build up
) - 1の結果を、
Effect.runSync
を実行する (=Run
)
const program = divide(16,2); // ← Build up
const value = Effect.runSync(program); // ← Run
なぜステップを分離する必要があるかを考えます。
個人的には以下を実現するためと考えています。
- 「なにをどうするか」のコアロジックにたいして、「何を使うか」「どのように実行するか」といった付加的な内容を、後から指定 / 追加できるようにするため。
※ 実際に DBなどの依存や、Retry / 並列実行 / タイムアウトの指定などを後から指定することができる - 同期処理 / 非同期処理 を同列に扱えるようため
特に Effect
を利用した場合、2ステップに分かれることで Build up
時点 (= 関数に引数に指定した時点) では 関数の中身が実行されない
状態をつくることができます。 (厳密には実行される場合もあるが、それは副作用がない場合など実行されていても問題ない場合のみとなるので実質的に実行されていないという理解で問題ないと考えています。)
Build up
をした時点では、何をどの順番を実施するかを手順書を作成しているに過ぎません。まだ実際に処理を実施していないので手順の実施の仕方をチューニングする余地が残されています。
Effect
では、 後からチューニングできるものとして提供しているIFとして以下があります。
- 依存の解決
- タイムアウトの指定
- 並行処理の指定
- リトライの指定
…など
リトライを組み込もうとした場合の実装
const getJson = (url: string): Promise<unknown> =>
fetch(url).then((res) => {
if (!res.ok) {
throw new Error(res.statusText)
}
return res.json() as unknown
});
const retry = <T>(retryCount: number, func: () => Promise<T>):Promise<T> => _retry(retryCount, 0, func);
const _retry = <T>(retryCount: number, retriedCount: number, func: () => Promise<T>):Promise<T> => func().catch(e => {
if(retriedCount < retryCount){
return _retry(retryCount, retriedCount + 1, func);
} else {
throw e;
}
});
const getJsonWithRetry = (retry: number, url: string) => retry(3, getJson())
Effect
を利用してリトライやタイムアウトの指定を後から追加している例
import { Effect } from "effect"
import { UnknownException } from 'effect/Cause';
const getJson = (url: string): Effect.Effect<unknown, UnknownException> =>
Effect.tryPromise(() =>
fetch(url).then((res) => {
if (!res.ok) {
throw new Error(res.statusText)
}
return res.json() as unknown
})
)
const program = (url: string) =>
getJson(url).pipe(
// ^^^^^^^^^ データの取得、コアなロジック
Effect.retry({ times: 2 }),
// ^^^^^^^^^ retryの指定
Effect.timeout("4 seconds"),
// ^^^^^^^^^^^ タイムアウトの指定
)
実際にデータ取得を行う実装箇所 getJson(url)
の記載した後に、リトライの指定やタイムアウトの指定ができていることがわかると思います。
この コアなロジックに追加要素を付加することができる
仕組みとすることで、よりコアなロジックを結合度が低く再利用しやすい状態で実現することができます。
また認知負荷の観点においても、まずコアなロジックの(一番関心が強い)getJson(url)
が登場し、その後比較的関心が低いリトライ、タイムアウトなどの指定がされていることが確認することができる構成はすぐれていると感じます。
結果の値
に対して、成否の状態を混在させた Result
型ではこれは難しいです。Result
の中身としては、すでに 結果
が出ているため後からDBの接続先やリトライの指定をしても、反映させることはできません….。
Effect
の場合には、 結果ではなく複数の処理を組み合わせた手順を実施した際に、成否の状態の両方を考慮して実施できるようにしたもの…であるから実現することができます。
「どんな処理をするかは事前に決まってはいるものの、処理を実際に実施するために必要な環境や依存は後から指定する余地が残されている」というのが1つの大きな特徴と言えると思います。
これを実現することで、実際にDBなどの外界に依存しないコアなロジック(より関心の強いロジック)を疎結合な状態に記述することができ、後から依存やRetry / Timeout など指定して実行することが Effect
のサポート範囲内で実現することができるようになります。
Run の際のエラーの取り扱い
Effect
で Run
を実施した場合、成功地の値が取得され、失敗の場合には Effect.fail
で指定された値が Throw されます。
この挙動は Result
と異なりますが、できるだけ値の抽出処理を簡易化させたいといる意図があると考えられます。
実際に公式が公開している動画の中で解決すべき課題の1つとして、 Result
型の値のチェック処理が挙げられています。
通常のResult型では…
Forced to handle errors at every single point, even if we don't care
「関心がなくとも都度チェックしなければならない」と表現されている
Effect.fail
の場合には失敗した値が Throw されてしまうのでエラー制御どうするの…!?これを管理するために成功時と失敗時の両方を管理できるようにしたのではないの…!?という疑問があります。
しかしこれは、Result
型の値のチェックをより簡易化するための試みで、 原則的にはEffect
型を継続的に使い続け、エントリーポイントの直前で必要な制御 /変換 をすることが推奨されてます。
実際に公式が公開している動画の中で解決すべき課題の1つとして、 Result
型の値のチェック処理が挙げられています。
実際に Effect (公式) が公開している動画で
Run "Effect"s at the EDGES of your program
「Effectを実行するとき(=Effectに包まれている中の値を取り出すとき)はエントリーポイントなどの端点で実施するべきだ」
と表現されています。
こんなイメージ
- 外界からの入力に対して
Effect
型に変換してから取り扱うようにする - プログラム上は
Effect
型を維持したまま処理を継続させる - 外界に出力する際に、
Effect
型から値を抽出する
非同期処理の取り扱い
また Effect
では、同期処理 / 非同期処理 によらずすべて同様の Effect
として同列に管理することもできます。
const delay = (ms: number) =>
Effect.promise(async () => {
if (ms < 0) {
return Effect.fail(new Error('ms is less than 0'));
}
await new Promise(resolve => setTimeout(resolve, ms));
return Effect.succeed(ms);
}).pipe(Effect.flatten);
const program = delay(100);
// ^^^^^^^ Effect.Effect<number, Error, never>
const value = await Effect.runPromise(program);
// ^^^^^ number
Effect
型の関数を生成する際の処理と、 Run
の際の処理が異なりますが、 一度Effect
型にしてしまえば、後は実行時まで同期関数と同じように取り扱うことができます。
まとめ
-
Effect
はエラー制御以外もサポートしており、複雑なプログラムを簡潔に記載することを可能とするためのもの。 -
Result
と異なりBuild up
/Run
の2ステップが必要となり、これは複雑なプログラムをコアなロジックの独立性を維持したまま、より柔軟により簡潔に記載することを可能とします。 -
Result
とは異なり実行時には Error が Throw されますが、これは成否のチェックの煩わしさは極小化するためのものです。エントリーポイントなど外界との接点でのみ、Effect
からの値の抽出を実施し、その他の部分においてはEffect
のまま取り扱うことが推奨されています。またこれを実現するための機能も提供されています。 - 同期関数 / 非同期関数は同列で取り扱うことができます。
- 上記点の点など今までの実装とは大きく異なる点もあり、また提供している機能も多岐に渡るので、混乱や誤用まねくリスクもあります。本格的に利用する場合には必要に応じて運用方法は整えるのが良いです。
※ 運用方法をどのように整備するのが良いかは他資料にまとめる予定です。
Discussion