🧯

effect-ts かなり使えそう

に公開

effect-ts が爆伸びしてるみたい.

Star History Chart

気になって調べてみて,結構概念から難しいけど,一回理解するとかなり便利に使えそう.
Effect system を元にしてて,読んでもよくわからなかったので,effect-tsの実用ケースから理解することにした.

Docs for llms.txt があるので,それを gemini に読ませて疑問に答えてもらう.

フロントエンド,バックエンドでのそれぞれのケースでどう使えるかを見ていく.


effect-tsが実際の開発でどのように役立つか、具体的なユースケースを基にbefore(未使用時)とafter(使用時)のコードを比較し、その概念とともに解説します。

ここでは、多くのWebアプリケーションで共通して見られる「外部APIからデータを取得し、そのデータに基づいて複数の関連処理を並行実行する」というシナリオを取り上げます。


シナリオ:TODOリストのオーナーへの一斉通知

  1. TODOリストをAPIから取得する (/todos)
  2. 各TODOアイテムについて、そのオーナー(ユーザー)の情報をAPIから取得する (/users/:id)
  3. 取得したユーザー情報(名前とメールアドレス)を使い、各ユーザーに通知メールを送信する (/sendEmail)

このシナリオには、非同期処理の連鎖、エラーハンドリング、並行処理、パフォーマンス(N+1問題)、依存関係の管理といった、現実の開発で直面する多くの課題が含まれています。


Before: Promiseasync/awaitを使った従来の実装

まずは、effect-tsを使わずに、標準的なasync/awaitPromiseでこのシナリオを実装してみましょう。

// before.ts

// --- モデル定義 ---
interface Todo {
  id: number;
  message: string;
  ownerId: number;
}
interface User {
  id: number;
  name: string;
  email: string;
}

// --- API呼び出し関数 ---
async function fetchApi<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    // エラーの型が曖昧 (Error, TypeError, etc.)
    throw new Error(`API request failed: ${response.statusText}`);
  }
  return response.json() as Promise<T>;
}

const getTodos = () => fetchApi<Todo[]>("https://api.example.demo/todos");
const getUserById = (id: number) => fetchApi<User>(`https://api.example.demo/users/${id}`);
const sendEmail = (address: string, text: string) => fetchApi<void>("/send-email");

// --- メインロジック ---
async function notifyTodoOwners() {
  console.log("Fetching todos...");
  try {
    const todos = await getTodos();

    // 各TODOに対して非同期処理をループ内で実行
    // N+1問題が発生: 1回のgetTodos + N回のgetUserById + N回のsendEmail
    const notificationPromises = todos.map(async (todo) => {
      try {
        const user = await getUserById(todo.ownerId);
        console.log(`Notifying ${user.name}...`);
        const message = `Hey ${user.name}, you have a new todo: "${todo.message}"`;
        await sendEmail(user.email, message);
      } catch (error) {
        // エラーの型が`unknown`なので、具体的な処理がしづらい
        console.error(`Failed to process notification for todo ${todo.id}:`, error instanceof Error ? error.message : "Unknown error");
        // このエラーは握りつぶされ、全体の処理は続行される
      }
    });

    await Promise.all(notificationPromises);
    console.log("All notifications processed.");

  } catch (error) {
    // getTodosが失敗した場合のエラーハンドリング
    console.error("Critical error fetching todos, stopping process.", error);
  }
}

notifyTodoOwners();

概念と課題の解説

この従来のアプローチには、いくつかの典型的な課題があります。

  1. 曖昧なエラーハンドリング:
    try...catchブロックでエラーを補足しますが、catch (error)errorunknown型です。そのため、エラーの種類を判別するにはinstanceofなどを使った手動の型ガードが必要になり、コードが煩雑になります。また、どの関数がどの種類のエラーをthrowする可能性があるのか、型シグネチャから読み取ることはできません。

  2. パフォーマンス問題 (N+1):
    todos.mapの中でgetUserByIdawaitしているため、TODOアイテムの数(N)だけAPIリクエストが逐次または並行で発生します。もし複数のTODOが同じオーナーを持つ場合でも、同じユーザー情報を何度も取得しに行くため、非効率です。

  3. 依存関係の隠蔽:
    fetchApiのような関数は、fetchというグローバルな機能に暗黙的に依存しています。これをテストするには、jest.spyOnmswのようなライブラリを使ってfetch自体をモックする必要があり、テストの準備が複雑になります。

  4. 合成可能性の低さ:
    リトライ、タイムアウト、より高度な並行制御(例:一度に5件ずつ処理する)といったロジックを追加しようとすると、手動で実装する必要があり、メインロジックがどんどん複雑化します。


After: effect-tsを使ったモダンな実装

次に、同じシナリオをeffect-tsで実装します。ここでは、型安全なエラー、依存性注入、そして宣言的なバッチ処理といったeffect-tsの強力な機能を活用します。

// after.ts

import { Effect, Data, Context, Layer, Request, RequestResolver } from "effect";

// --- モデルと型安全なエラー定義 ---
// Data.TaggedErrorを使うことで、エラーに一意のタグが付き、判別が容易になる
class GetTodosError extends Data.TaggedError("GetTodosError")<{ cause: unknown }> {}
class GetUserError extends Data.TaggedError("GetUserError")<{ cause: unknown }> {}
class SendEmailError extends Data.TaggedError("SendEmailError")<{ cause: unknown }> {}

interface Todo { id: number; message: string; ownerId: number; }
interface User { id: number; name: string; email: string; }

// --- バッチ処理のためのリクエスト定義 ---
// 同じ種類のリクエストをEffectが自動でまとめてくれる
interface GetUserById extends Request.Request<User, GetUserError> {
  readonly _tag: "GetUserById";
  readonly id: number;
}
const GetUserById = Request.tagged<GetUserById>("GetUserById");


// --- 依存関係(サービス)の定義 ---
// ApiClientというサービスを定義し、具体的な実装(fetchなど)からロジックを分離する
class ApiClient extends Context.Tag("ApiClient")<
  ApiClient,
  {
    readonly getTodos: Effect.Effect<Todo[], GetTodosError>;
    readonly getUsers: (reqs: GetUserById[]) => Effect.Effect<User[], GetUserError>;
    readonly sendEmail: (address: string, text: string) => Effect.Effect<void, SendEmailError>;
  }
>() {}


// --- バッチ処理の実装(リゾルバ) ---
// 複数のGetUserByIdリクエストを1回のAPI呼び出しにまとめるロジック
const GetUserByIdResolver = RequestResolver.makeBatched(
  (requests: GetUserById[]) => ApiClient.pipe(
    Effect.flatMap(client => client.getUsers(requests)),
    Effect.andThen(users => 
      // 結果を各リクエストに割り振る
      Effect.forEach(requests, (req, i) => Request.completeEffect(req, Effect.succeed(users[i]!)))
    ),
    Effect.catchAll(error => Effect.forEach(requests, req => Request.fail(req, error)))
  )
).pipe(RequestResolver.contextFromServices(ApiClient));


// --- サービスの具体的な実装 (Layer) ---
// このLayerを差し替えるだけで、テスト時に簡単にモックできる
const ApiClientLive = Layer.succeed(
  ApiClient,
  ApiClient.of({
    getTodos: Effect.tryPromise({
      try: () => fetch("https://api.example.demo/todos").then(res => res.json() as Promise<Todo[]>),
      catch: (cause) => new GetTodosError({ cause })
    }),
    getUsers: (requests) => Effect.tryPromise({
        // 実際には `/users?ids=1,2,3` のようなバッチエンドポイントを叩く想定
        try: () => fetch("https://api.example.demo/getUsersBatch", {
            method: "POST",
            body: JSON.stringify({ ids: requests.map(r => r.id) })
        }).then(res => res.json() as Promise<User[]>),
        catch: (cause) => new GetUserError({ cause })
    }),
    sendEmail: (address, text) => Effect.tryPromise({
      try: () => fetch("/send-email", { method: "POST", body: JSON.stringify({ address, text }) }),
      catch: (cause) => new SendEmailError({ cause })
    })
  })
);


// --- メインロジック ---
const notifyOwner = (todo: Todo) => Effect.gen(function*(_) {
  // `Effect.request`がリクエストを発行し、リゾルバが自動でバッチ処理する
  const user = yield* _(Effect.request(GetUserById({ id: todo.ownerId }), GetUserByIdResolver));
  const message = `Hey ${user.name}, you have a new todo: "${todo.message}"`;
  
  // ApiClientサービスからsendEmailを呼び出す
  const apiClient = yield* _(ApiClient);
  yield* _(apiClient.sendEmail(user.email, message));
  yield* _(Effect.log(`Successfully notified ${user.name}`));
});

const program = Effect.gen(function*(_) {
  yield* _(Effect.log("Fetching todos..."));
  const apiClient = yield* _(ApiClient);
  const todos = yield* _(apiClient.getTodos);

  // forEachで並行処理。`batching: true`でバッチ処理を有効化
  yield* _(
    Effect.forEach(todos, notifyOwner, { concurrency: "unbounded", batching: true })
  );
  yield* _(Effect.log("All notifications processed."));
}).pipe(
  // 型安全なエラーハンドリング。特定のエラーだけをcatchできる
  Effect.catchTag("GetTodosError", (e) => Effect.logError("Failed to get todos", e))
);


// --- プログラムの実行 ---
// 依存するApiClientLiveレイヤーを提供して、実行可能なEffectを作成
const runnable = program.pipe(Effect.provide(ApiClientLive));

// プログラムを実行
Effect.runPromise(runnable);

概念と利点の解説

effect-tsを使った実装は、多くの点で従来のアプローチを改善します。

  1. 型安全なエラーハンドリング:
    Data.TaggedErrorで定義したエラーは、Effectの第二型パラメータEとして静的に追跡されます。これにより、programの型シグネチャを見るだけで、GetTodosErrorが発生する可能性があると分かります。Effect.catchTagを使えば、特定のエラー型だけを安全に補足でき、switch文やinstanceofによる煩雑な分岐が不要になります。

  2. 宣言的なパフォーマンス最適化 (バッチ処理):
    RequestRequestResolverの仕組みを利用することで、N+1問題を宣言的に解決できます。Effect.forEachループ内でEffect.request(GetUserById(...))を呼び出しても、effect-tsのランタイムがこれらのリクエストを自動的に収集し、GetUserByIdResolverにまとめて渡してくれます。これにより、同じユーザーIDに対するリクエストは1回にまとめられ、ネットワーク通信の回数が劇的に削減されます。ロジック自体は「各TODOに対してユーザーを取得する」という自然な記述のまま、実行時に最適化されるのが大きな利点です。

  3. 依存性注入 (DI) とテストの容易さ:
    ApiClientという「サービス」をContext.Tagで定義し、その具体的な実装をApiClientLiveというLayerとして分離しました。ビジネスロジック(program)はApiClientというインターフェースにのみ依存し、fetchを使った具体的な実装を知りません。
    これにより、テスト時にはApiClientLiveをモックしたApiClientTestというLayerに差し替えるだけで、API通信なしにロジックを単体テストできます。

  4. 高い合成可能性と宣言的なコード:
    Effectは、それ自体がプログラムの「設計図」です。pipeEffect.genを使って、小さな処理の断片を組み合わせて大きなプログラムを構築できます。例えば、programにリトライ機能を追加したければ、Effect.retry(Schedule.exponential("1 second"))pipeで繋ぐだけです。ビジネスロジックと、リトライや並行処理といった実行戦略を明確に分離して記述できます。

まとめ

課題 Before (Promise) After (effect-ts)
エラーハンドリング try-catch(e: unknown)で型が不明。手動での型ガードが必要。 Effect<A, E, R>でエラー型を静的に追跡。catchTagで型安全にハンドリング。
パフォーマンス N+1問題が発生しやすく、手動での最適化が必要で複雑。 RequestResolverにより、宣言的なバッチ処理でN+1問題を自動的に解決。
依存関係 fetchなどに暗黙的に依存。テスト時のモックが面倒。 ContextLayerで依存関係を明示的に分離。テストが容易になる。
コードの合成 リトライやタイムアウトなどのロジックは手動で実装する必要があり、可読性が低下。 retry, timeoutなどの豊富なコンビネータにより、宣言的に高度なロジックを合成可能。

このようにeffect-tsは、単に非同期処理を扱うだけでなく、エラーハンドリング、依存性注入、パフォーマンス最適化といった、堅牢なアプリケーションを構築する上で不可欠な要素を、型安全かつ宣言的な方法で提供します。これにより、コードの信頼性と保守性が大幅に向上します。


frontend spa (e.g. react), backend どっちもで使える?どういうケース?

はい、effect-tsフロントエンド(ReactなどのSPA)とバックエンド(Node.jsなど)の両方で非常に効果的に利用できます。プラットフォームに依存しないコアライブラリであり、それぞれの環境の特性に合わせて活用方法があります。


フロントエンド(React SPA)でのユースケース

フロントエンド開発では、effect-tsは特に状態管理、非同期API通信、そして複雑なUIロジックの整理に大きな力を発揮します。

1. API通信と状態管理の統合

シナリオ: ユーザー情報をAPIから取得し、ローディング状態やエラー状態をUIに反映させたい。

Before (useEffect, useState):

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error("Failed to fetch");
        return res.json();
      })
      .then(data => setUser(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return null;

  return <h1>{user.name}</h1>;
}

このコードは一般的ですが、loading, error, dataという3つの状態を個別に管理する必要があり、コンポーネントが複雑になるにつれて管理が煩雑になります。

After (effect-ts):
effect-tsを使うと、これらの状態をExit(またはCause)という一つの概念でまとめて管理できます。

// services.ts
import { Effect, Schema, Data } from "effect";

// APIクライアントの定義
const User = Schema.Struct({ id: Schema.Number, name: Schema.String });
class ApiError extends Data.TaggedError("ApiError")<{ cause: unknown }> {}

const getUser = (id: number) => Effect.tryPromise({
  try: () => fetch(`/api/users/${id}`).then(res => res.json()),
  catch: (cause) => new ApiError({ cause })
}).pipe(Effect.flatMap(Schema.decode(User)));
// UserProfile.tsx
import React, { useMemo } from 'react';
import { Effect, Exit } from 'effect';
import { useRunEffect } from './useRunEffect'; // カスタムフック
import { getUser } from './services';

function UserProfile({ userId }) {
  const program = useMemo(() => getUser(userId), [userId]);

  // programを実行し、その結果(Exit)をリアクティブに受け取るカスタムフック
  const result = useRunEffect(program); 

  return Exit.match(result, {
    onSuccess: (user) => <h1>{user.name}</h1>,
    onFailure: (cause) => {
      if (cause._tag === "Fail" && cause.error._tag === "ParseError") {
        return <div>Invalid data format received.</div>;
      }
      return <div>Error loading user.</div>;
    },
    // 初期状態やローディング中
    // `useRunEffect`フックが初期状態を管理してくれる
    onInitial: () => <div>Loading...</div> 
  });
}

useRunEffectEffect.runPromiseExitをラップし、結果をuseStateで管理するようなカスタムフックを想定しています。

利点:

  • 状態の統一: ローディング、成功、失敗の状態がExit型で表現され、コンポーネントはそれをmatchするだけでよくなり、状態管理がシンプルになります。
  • 型安全なエラー: APIからのレスポンスの型が不正な場合(ParseError)と、ネットワークエラー(ApiError)を型レベルで区別し、それぞれ異なるUIを表示できます。

2. 複雑なUIロジックの宣言的な記述

シナリオ: フォーム入力があり、入力値が変更されるたびにデバウンスをかけつつAPIでバリデーションを行い、結果を表示したい。

Before (setTimeout, clearTimeout):
手動でタイマーを管理する必要があり、クリーンアップも煩雑です。

After (Effect.debounce):
effect-tsのコンビネータを使えば、宣言的に記述できます。

// フォームの入力値を監視するEffect
const validateUsername = (username: string) => 
  api.validate(username).pipe(
    Effect.debounce("500 millis") // 500msのデバウンス
  );

利点:

  • 宣言的: setTimeoutなどの命令的なAPIを直接使わず、「500msデバウンスする」という意図を直接コードで表現できます。
  • 中断可能: Effectは中断(キャンセル)可能です。ユーザーがコンポーネントを離れた場合、effect-tsのランタイムが自動的に進行中のデバウンスやAPIリクエストをキャンセルしてくれるため、メモリリークや意図しないコールバックの実行を防げます。

バックエンド(Node.js)でのユースケース

バックエンドでは、effect-ts堅牢性、パフォーマンス、保守性の高いサーバーアプリケーションの構築に貢献します。

1. 依存性注入(DI)によるモジュール化とテスト容易性

シナリオ: ユーザー登録APIを実装する。ロジックは、データベースへの書き込みと、メール送信サービスへの通知を必要とする。

Before (手動でインスタンスを渡す):

// userService.ts
import { Database } from './db';
import { Mailer } from './mailer';

export class UserService {
  constructor(private db: Database, private mailer: Mailer) {}

  async register(email: string) {
    const user = await this.db.createUser(email);
    await this.mailer.sendWelcomeEmail(user.email);
    return user;
  }
}

この場合、UserServiceのインスタンスを作成する場所でDatabaseMailerのインスタンスを生成して渡す必要があり、依存関係が密になります。

After (LayerによるDI):

// services.ts
import { Context, Layer, Effect } from "effect";

// 各サービスのインターフェースを定義
class Database extends Context.Tag("Database")<Database, { /* ... */ }>() {}
class Mailer extends Context.Tag("Mailer")<Mailer, { /* ... */ }>() {}

// UserServiceはインターフェースにのみ依存
class UserService extends Context.Tag("UserService")<UserService, {
  register: (email: string) => Effect.Effect<User, DbError | MailerError>
}>() {}

// UserServiceの具体的な実装をLayerとして定義
const UserServiceLive = Layer.effect(
  UserService,
  Effect.gen(function*(_) {
    const db = yield* _(Database);
    const mailer = yield* _(Mailer);
    // ...ロジックを実装
    return { register: (email) => { /* ... */ } };
  })
);

利点:

  • 関心の分離: UserServiceのロジックは、DatabaseMailerの具体的な実装方法(MySQLなのかPostgreSQLなのか、SendGridなのかSMTPなのか)を知る必要がありません。
  • テスト容易性: テスト時には、DatabaseLiveMailerLiveの代わりに、インメモリのDatabaseTestやコンソールに出力するだけのMailerTestといったモックのLayerprovideするだけで、簡単にテスト環境を構築できます。

2. リソース管理の安全性

シナリオ: データベース接続プールからコネクションを取得し、クエリを実行した後、コネクションを確実にプールに返却したい。

Before (try...finally):

async function query(pool) {
  let client;
  try {
    client = await pool.connect();
    return await client.query('SELECT * FROM users');
  } catch (e) {
    // エラー処理
  } finally {
    if (client) {
      client.release(); // 確実に実行する必要がある
    }
  }
}

finallyを書き忘れると、リソースリークに繋がります。

After (Effect.acquireRelease):

const pool = new Pool(); // DB接続プール

const connect = Effect.tryPromise({
  try: () => pool.connect(),
  catch: () => new DbError()
});

const release = (client) => Effect.sync(() => client.release());

// acquireReleaseでリソースの取得と解放をセットで定義
const scopedClient = Effect.acquireRelease(connect, release);

const query = Effect.scoped(
  scopedClient.pipe(
    Effect.flatMap(client => 
      Effect.tryPromise(() => client.query("SELECT * FROM users"))
    )
  )
);

利点:

  • リソース安全性: acquireReleaseは、スコープが終了する際に(成功、失敗、中断を問わず)必ずrelease処理を実行することを保証します。finallyブロックを書き忘れるといったヒューマンエラーを防ぎます。
  • 合成可能性: scopedClientというリソースを、他のEffectと安全に組み合わせることができます。

まとめ:どちらの環境でどう使うか

環境 主な利用ケース effect-tsが解決する課題
フロントエンド - API通信とUIの状態管理
- 複雑な非同期UIロジック(デバウンス、スロットリング)
- クライアントサイドでの状態管理
- loading/error/dataの煩雑な状態管理
- Promiseチェーンの可読性の低さ
- 非同期処理の中断とクリーンアップ
バックエンド - 堅牢なAPIサーバーの構築
- 依存性注入(DI)によるモジュール化
- 安全なリソース管理(DB接続、ファイルハンドル)
- パフォーマンス最適化(バッチ処理、並行処理)
- 密結合なアーキテクチャ
- テストの難しさ
- リソースリークのリスク
- N+1問題などのパフォーマンスボトルネック

結論として、effect-tsはフロントエンドとバックエンドの両方で、それぞれのドメイン特有の課題を解決するための強力なツールセットを提供します。フロントエンドではUIの状態と非同期処理の複雑さを、バックエンドではシステムの堅牢性、保守性、パフォーマンスを向上させるために役立ちます。

Discussion