🌟

「Effect」ってなんだろう?

2024/11/25に公開

この記事はなに?

TypeScriptのライブラリの1つである Effect の導入してみて得られた知見と所感をもとに、 Effect の概要に関して内容をまとめたものです。

実際に導入を進めるにあたり公式ドキュメントを参照しましたが、既存の Result 型と思想が異なる部分があり、すんなり理解できなかった箇所がありました…。他の Result 型と比較して、何が異なるのか / なぜ異なるのか / 何を解決しようとしているのか を自身の所感も添えてまとめています。

今後 Effect の導入を考えている開発者が少しでも、 Effect がどんな特徴を持ちどのような思想に基づいたものなのかの理解の助けになれば幸いです。

※ Result型については以下参照
https://zenn.dev/koudai/articles/ff415ca6755054

Effcetとは?

Effect は開発者が複雑なエラーや非同期処理をより安全に開発できるようにすることを目的とした TypeScript ライブラリです。2024/04 に安定版の v3 がリリースされています。

TSKaigi2024の発表の中でも題材として取り上げられている関数型のライブラリです。

Effect System という概念を取り入れており、Scala や Haskell といった関数型プログラミング言語に影響を受けて作成されています。エラー制御だけでなく、DI や Telemetry 、リソース管理、状態管理 など幅広い機能を提供しています。

Concept

Effect は複雑なアプリケーションの実装を簡潔に実現することを可能にすることを目的とし、これを実現するために数多くの機能を提供しています。

Effect を利用することで簡潔に実装できるか…を、公式のトップページにわかりやすく表現されています。

https://effect.website/

1. エラー制御の対応を入れた場合


←(左) が Effect を利用せずに実装した場合
→(右) が Effect を利用した場合

背景に色がついている箇所が、同等の内容の実装箇所となっています。総じて Effect での実装が簡潔であることが示されています。

2. Retryの制御を入れた場合

3. タイムアウトの制御を入れた場合

4. Traceの仕組みを入れた場合

Effect を利用せずに実装した場合 (Without Effect) の事例はやや極端…といより実際には共通化して切り出したらここまで行数かからないよね…とは思うものの Effect の有用性として、「複雑なアプリケーションの実装を簡潔に実現する」という可能性はなんとなく感じ取れるかと思います。

Function List

Effect では非常に多く機能を備えています。公式ドキュメントでその片鱗を確認することができます。以下は、公式ドキュメントに記載されているメニューの一覧を以下に記載します。

  1. Error Management
    Effect でのエラーの概念とその取り扱い方法について
  2. Requirements Management
    依存関係を Effect でどのように取り扱い、解決するかについて
  3. Resource Management
    データベースなどの外部リソースとの連携方法について。リソースの解放や外部連携時のロールバックの仕組みについて
  4. Observability
    ログやテレメトリについて
  5. Configuration
    環境変数やシークレットの管理、取り扱いについて
  6. Runtime
    実行環境の定義や管理、活用方法について
  7. Scheduling
    リトライやタイムアウトの指定方法について
  8. State Management
    状態管理や参照アクセスを許容する変数の取り扱いについて
  9. Batching
    一括実行について
  10. Caching
    アプリケーションのパフォーマンスを最適化するためのキャッシュ機能について
  11. Concurrency
    並行処理の概念と操作方法について
  12. Streaming
    有限リストの処理から無限シーケンスの処理について
  13. Testing
    テストの実行方法について
  14. Control Flow
    制御フローを管理するためのさまざまな関数とその活用方法について。whenifforEach などについて
  15. Code Style
    実装スタイルについて
  16. 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 の場合

  1. 関数の定義

    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 が返却されることが示されています。

  2. 処理結果の確認

    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 は型推論がされるので、利用する側はどんなエラーが発生するかを理解した上でかつエラー時の分岐や処理を記載することが強制されるのでより堅牢な実装を実現することができます。

  3. 処理の結合
    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 型のまま処理を継続し、最後に値を抽出することができます。

    • flowpipe などを用いて関数自体の合成する場合

      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 を使った場合

  1. 関数の定義

    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 で包んで返却します。

  2. 処理結果の確認

    const program = divide(16,2);
    //    ^^^^^^^ Effect<number, Error>
    
    const value = Effect.runSync(program); // throw error
    //    ^^^^^ number
    

    関数の定義とは対照的に、処理結果は特に以下2点において異なります。

    1. 関数に引数を渡しただけでは、結果を得ることはできません。 Effect.runSync を実施することで結果を得ることができます。
    2. 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 の特異性

上の例で見たように、特に以下の点において EffectResult と異なります。

  1. 関数に引数を渡して porgram を作成する(= Build up )、処理の実行の Run の2ステップに分けられます
  2. Run をした際には、成功時の値が返却され失敗時の値は Throw されます
  3. なんか R (=Requirement) が追加されている

上記の特異性で何を 解決できるのか / 解決しようとしているのか を考えてみます。

Build up / Run ステップの分離

先で見たとおり、Result型とは異なり以下の2つのステップが必要となります。

  1. 引数を渡して program を作成する (= Build up )
  2. 1の結果を、 Effect.runSync を実行する (= Run )
const program = divide(16,2); // ← Build up
const value = Effect.runSync(program); // ← Run

なぜステップを分離する必要があるかを考えます。
個人的には以下を実現するためと考えています。

  1. 「なにをどうするか」のコアロジックにたいして、「何を使うか」「どのように実行するか」といった付加的な内容を、後から指定 / 追加できるようにするため。
    ※ 実際に DBなどの依存や、Retry / 並列実行 / タイムアウトの指定などを後から指定することができる
  2. 同期処理 / 非同期処理 を同列に扱えるようため

特に Effect を利用した場合、2ステップに分かれることで Build up 時点 (= 関数に引数に指定した時点) では 関数の中身が実行されない 状態をつくることができます。 (厳密には実行される場合もあるが、それは副作用がない場合など実行されていても問題ない場合のみとなるので実質的に実行されていないという理解で問題ないと考えています。)

Build up をした時点では、何をどの順番を実施するかを手順書を作成しているに過ぎません。まだ実際に処理を実施していないので手順の実施の仕方をチューニングする余地が残されています。

Effect では、 後からチューニングできるものとして提供しているIFとして以下があります。

  1. 依存の解決
  2. タイムアウトの指定
  3. 並行処理の指定
  4. リトライの指定
    …など

リトライを組み込もうとした場合の実装

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 の際のエラーの取り扱い

EffectRun を実施した場合、成功地の値が取得され、失敗の場合には 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に包まれている中の値を取り出すとき)はエントリーポイントなどの端点で実施するべきだ」
と表現されています。

こんなイメージ

  1. 外界からの入力に対して Effect 型に変換してから取り扱うようにする
  2. プログラム上は Effect 型を維持したまま処理を継続させる
  3. 外界に出力する際に、 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 のまま取り扱うことが推奨されています。またこれを実現するための機能も提供されています。
  • 同期関数 / 非同期関数は同列で取り扱うことができます。
  • 上記点の点など今までの実装とは大きく異なる点もあり、また提供している機能も多岐に渡るので、混乱や誤用まねくリスクもあります。本格的に利用する場合には必要に応じて運用方法は整えるのが良いです。

※ 運用方法をどのように整備するのが良いかは他資料にまとめる予定です。

Bitkey Developers

Discussion