フロントエンドエンジニアがEffect-TSの導入を検討してみた

こんにちは!フロントエンドエンジニアの張です!

この記事はEnigmo Advent Calendar 2025の8日目の記事です。

「型安全」、「堅牢性」、「開発体験」、どれもエンジニアでしたら、近年よく聞くキーワードだと思います。特にウェブ開発、フロントエンド開発界隈では、それらを改善するためにTypeScriptを導入・採用するチームが増える一方です。 でも、「それでは足りない、もっと堅牢的、かつ保守しやすいTypeScriptを書きたい!」だと主張するコミュニティが実は存在していて、彼らがたどり着いた解決策は今回私が導入を検討したEffect TSです。

私の調査を説明する前に、記事をわかりやすくするために、まずはいくつかのキーワードを定義したいと思います。

キーワード

型安全

プログラミング言語、あるいは一部の言語(TypeScriptなど)においては、ライブラリを形容する単語。型安全な言語・ライブラリはコンパイル時あるいはランタイム(実行時)に型の誤用を検知できて、開発者にそれを報告したり、プログラムを中止したりすることができます。 コンパイル時、ランタイム両方で型の誤用を徹底的に防ぐRustは代表格の一つです。 Rust ほどではありませんが、今回のテーマになるTypeScript も型安全な言語だと認識されています。

堅牢性

プログラムが想定した形式で様々なシナリオを対応する能力です。具体的に話しますと、可能なエラーを検知して、適切な処理を実行したり、無効なインプットに対して、正しい処理で対処したりすることも堅牢性の高いソフトウェアの指標となります。

開発体験

簡単に言いますと、開発の快適さという意味です。エディター(VS Codeなど)とLSP (Language Server Protocol) で実現されたオートコンプリートとかも開発体験を改善してます。ほかには、LLM によるコード予測なども開発体験を向上させていると思われています。

導入を検討した理由

キーワードを簡単に説明できましたので、今回導入を検討した理由を説明したいと思います。

実は所属しているチームが来年からコードベースをMeta社のFlow-Typed から TypeScript に移行する予定なので、私は理想的な TypeScript の書き方を模索中です。 その中、Effect TS という気になっていたライブラリを思い浮かんで、調査を始めました。

気になっていたところなんですが、まさに「型安全」と「堅牢性」の部分でした。

Effect Type

その名前の通り、Effect TSの骨幹となるのは、Effect というデータストラクチャーです。Effect TSにおいて、一切の処理の起点が Effectになっていて、なにかを実行するため、まずはその処理を説明するEffectを実装しないといけません。

そして、そのEffect ですが、従来の JavaScript のオブジェクトとも関数とも違って、処理の結果(success type)、エラー(error type)と依存関係(dependencies)を内包しています。わかりやすく説明しますと、Rustにある Result Type に似ています。

そのシグネチャが以下になります

Effect<Success, Error, Requirements>

Error Type

依存関係については話を更に複雑にしますので、今回は割愛させていただきますが、エラー はプログラムの「型安全」と「堅牢性」を大幅に改善することができます。

理由としては、TypeScript を含むおおよそのプログラミング言語はエラーを型の一部として認識していませんので、型で完全に処理が正しく実行されることを保証できません。

たたえば、以下はtsc (TypeScript Compiler) から何の警告もないんですが、 fallibleFn が失敗して、console.log まで実行されない可能性がありますが、Effect TSRust などエラーを型として扱うタイプシステムではそういうことが殆ど発生しません。

const fn = () => {
  const result = fallibleFn();

  console.log(`The result is: ${result}`);
};

const fallibleFn = () => {
  if (Math.random() > 0.5) {
    return "hello world";
  } else {
    throw new Error("oops");
  }
};

Effect TSでの実装では、tscの型評価で関数が失敗することがあることがわかります

const fnEffect = Effect.gen(function* () {
  const result = yield* fallibleEffect;

  yield* Console.log(`The result is ${result}`);
});

const fallibleEffect = Effect.try(() => {
  if (Math.random() > 0.5) {
    return "hello world";
  } else {
    throw new Error("oops");
  }
});

error typeが UnknownExceptionになっています

堅牢性に関してですが、上述のように、エラーを持っているEffect を実行したら、返り値になる Effect も必ず エラーを持つことになります、それを完全になくすためにはそれを処理することは必要です。そして、処理する時、Effect TSは開発者が可能なエラーを全部対処することを要求します。

以下のスニペットinterestingFallibleEffect というエラーを持つ Effect をエラーなしの Effect にする処理です。可能なエラーを全部対応しないと、型からエラーが消えません。

class InterestingError extends Data.TaggedError("InterestingError")<{
  readonly message: string;
}> {}

class MoreInterestingError extends Data.TaggedError("MoreInterestingError")<{
  readonly message: string;
}> {}

const interestingFallibleEffect = Effect.gen(function*() {
  const random = Math.random();
  if (random > 0.5) {
    return "hello world";
  }

  if (random > 0.25) {
    return yield* Effect.fail(new InterestingError({ message: "interesting error" }));
  }

  return yield* Effect.fail(new MoreInterestingError({ message: "more interesting error" }));
});

const errorFreeInterestingFallibleEffect = interestingFallibleEffect.pipe(
  Effect.catchTags({
    InterestingError: () => Effect.succeed("failed interestingly"),
    MoreInterestingError: () => Effect.succeed("failed even more interestingly"),
  }),
);

エラーがなし(never)になっています

実装の詳細を色々端折って説明してしまいましたが、Effect TSが Error Type を通して「型安全」と「堅牢性」を改善できることがある程度伝えられたと思います。

結論として、私もできればソフトウェアを堅牢に作りたい方なので、今回の調査を決意しました。

既存コードをEffect TSで実装してみました

それでは本題に入ります!このたび、実際の業務にEffect TSを導入するか、検討するために、Buyma のフロントエンドのコードから適切な処理を選んで、Effect TSで実装してみました。

Effect TSの長所を活用するために、以下の基準で処理を選びました。

  • エラーが発生する可能性ある処理
  • リトライが必要な処理
  • お互いに依存性がある処理からできた処理

結果的に、こういう処理をEffect TSで実装してみました:

メッセージをサーバーに送る処理:

  1. メッセージを送る前に、サーバーにメッセージの内容を送って、バリデーションを実行してもらいます。失敗の場合、400系のstatus codeが返ります。認証エラーの場合だけ、トークン更新を実行して、リトライします。最終的に失敗していましたら、全体の処理が中止になります。
  2. メッセージの本文を送ります、3回までリトライが可能で、認証エラーの場合、リトライの前に、トークン更新を行いますが、更新が失敗した場合、リトライが中止になります。最終的に失敗した場合、全体の処理が中止になります。
  3. 2つ目の本文を送ります、3回までリトライが可能で、認証エラーの場合、リトライの前に、トークン更新を行いますが、更新が失敗した場合、リトライが中止になります。最終的に失敗した場合、全体の処理が中止になります。

Effect TS の書き方を体験するための実装なので、実装の内容は簡略化されています。フロントエンドの実装も含まれていますと、更に分かりにくくなるため、今回はHTTPリクエストの処理だけを実装しました。

APIを呼ぶ

まずは、HTTPリクエストを処理するEffectを作成しました。

const callAPI = (url: string) =>
  Effect.gen(function*() {
    const client = yield* makeAuthenticatedClient;

    const response = yield* client.get(url).pipe(
      Effect.catchAll((error) => {
        if (HttpClientError.isHttpClientError(error) && "response" in error) {
          const status = error.response.status;
          return Console.log(`API Error [${status}] at ${url}`).pipe(
            Effect.andThen(
              Effect.fail(
                new APIError({
                  message: `HTTP ${status} error at ${url}`,
                  status,
                }),
              ),
            ),
          );
        }

        return Console.log(`Network error at ${url}`).pipe(
          Effect.andThen(
            Effect.fail(
              new APIError({
                message: `Network error at ${url}`,
                status: 0,
              }),
            ),
          ),
        );
      }),
    );

    const json = yield* response.json.pipe(
      Effect.catchAll((_error) => {
        return Console.log(`JSON parse error at ${url}`).pipe(
          Effect.andThen(
            Effect.fail(
              new APIError({
                message: `Failed to parse JSON response at ${url}`,
                status: response.status,
              }),
            ),
          ),
        );
      }),
    );

    yield* Console.log(`Response from ${url}: ${json}`);

    return json;
  });

HTTP fetchのリクエストを発火して、エラーを APIError に統一して、成功する場合、結果を出力Effect です。

makeAuthenticatedClient というEffectからHTTP Clientを取得していますが、依存関係の話になりますので、詳細は割愛させていただきます。簡単に言いますと、Effect TSは依存性注入 を推奨していて、HTTP Clientなどの共通関数は依存性としてEffectに提供することが多く、今回私もそう実装しています。そして、fetch のエフェクト化のコストを省くために、@effect/platform パッケージの HttpClientを使用しております。

トークンを更新する

以上の実装でHTTPリクエストを発火することができるようになりましたので、次はトークンを更新する処理を実装しました。あくまで書き方を検証するための実装なので、簡略化された実装となります。

const renewToken = Effect.gen(function*() {
  yield* Console.log("Renewing token...");
  const client = yield* HttpClient.HttpClient;
  const { token } = yield* AuthToken;
  const response = yield* client.post(RENEW_URL);
  const json = yield* response.json;

  const newToken = (json as { token: string; }).token;

  yield* Ref.set(token, newToken);
  yield* Console.log("token renewed");
}).pipe(
  Effect.catchAll(() =>
    Effect.fail(
      new APIError({
        message: "Token renewal failed",
        status: 0,
      }),
    )
  ),
);

こちらはHttpClient以外に、AuthToken というサービスからEffect間に共有されているトークン(token) への参照(Ref) を取得していて、それ経由で共有の変数を更新しています。その変数は上述の makeAuthenticatedClient にも使われていますため、更新されたら、HTTP Clientが使うトークンが更新されたものになります。

リトライ、リカバリー機能を追加

基本の処理が揃いましたので、次はEffectをリトライするEffectに変えるEffectを実装します。

const retryWithRecovery = <A, E, R, RA, RE, RR>(
  effect: Effect.Effect<A, E, R>,
  recoveryAction: Effect.Effect<RA, RE, RR>,
  recoveryPredicate: (error: E) => boolean,
  retryPredicate: (error: E) => boolean,
  maxRetries: number,
): Effect.Effect<A, E | RE, R | RR> => {
  const attempt = (retriesLeft: number): Effect.Effect<A, E | RE, R | RR> =>
    effect.pipe(
      Effect.catchAll((e) => {
        if (retriesLeft > 0 && retryPredicate(e) && recoveryPredicate(e)) {
          return recoveryAction.pipe(Effect.andThen(attempt(retriesLeft - 1)));
        }
        if (retriesLeft > 0 && retryPredicate(e)) {
          return attempt(maxRetries - 1);
        }
        return Effect.fail(e);
      }),
    );
  return attempt(maxRetries);
};

ジェネリクス(generics) のせいで少し複雑に見えるかもしれませんが、よく見ますと結構簡単な処理です。

引数を説明しますと、

  • effect: メイン処理のeffect、成功時は A型の値を返して、失敗時はE型の値を返します。依存関係はR型です。
  • recoveryAction: 失敗したら、次のメイン処理を実行する前に実行されるeffect、次のメイン処理が失敗しないようにプログラムのステートをリカバーする処理です。成功時は RA型の値を返して、失敗時はRE型の値を返します。依存関係はRR型です。
  • recoveryPredicate: リカバリーを実行するか、判断する関数。trueが返されたら、リカバリーが実行されます。
  • retryPredicate: リトライを実行するか、判断する関数。trueが返されたら、リトライが実行されます。
  • maxRetries: 最大リトライ数。0になったら、recoveryPredicateretryPredicate の結果関係なしで、リトライが完了します。

引数の意味がわかりましたら、処理わかりやすくなるかと思います。つまり、この処理は、メイン処理にリトライする機能を追加しています。その上に、リトライの基準、トークン更新などのリカバリー処理、リカバリー処理を実行する基準を指定させることで、もっと柔軟なリトライ処理を作成することを可能にしています。単純にEffect をリトライしたいであれば、Effect.retry というヘルパーを使ったらいいですが、今回参考になった既存実装は実際リカバリーを考慮した実装なので、このように実装しました。

以上の処理は決して複雑な処理ではなくて、Effect TSなしでも実装できると思うかもしれませんが、Effect TSのおかげで、このEffectから生成された Effect は最終的に発生する可能性があるエラーと、必要な依存関係を全部型情報として保存しています。その凄さは最終の仕上げを見たら、お分かりになるかと思います。

仕上げ

以上実装したものを組み合わせて、仕上げたものが以下となります。

// バリデーション
const callAPI1 = retryWithRecovery(
  callAPI(API_1),
  renewToken,
  (e) => e.status === 401,
  (e) => e.status === 401,
  3,
);

// 本文1
const callAPI2 = retryWithRecovery(
  callAPI(API_2),
  renewToken,
  (e) => e.status === 401,
  () => true,
  3,
);

// 本文2
const callAPI3 = retryWithRecovery(
  callAPI(API_3),
  renewToken,
  (e) => e.status === 401,
  () => true,
  3,
);

// 順番で処理を呼ぶ
const callAPIs = callAPI1.pipe(Effect.andThen(callAPI2), Effect.andThen(callAPI3));

// 依存性注入
const mergedLayer = Layer.merge(FetchHttpClient.layer, AuthTokenLive);

const program = callAPIs.pipe(
  Effect.provide(mergedLayer),
  Effect.catchAll((error) => Console.log(`Main operation failed: ${error.message}`)),
);

Effect.runPromise(program);

前準備はまあまあ複雑でしたが、関数型のライブラリのため、基本の部品を準備できたら、最終の組み合わせは結構楽です。関数型にまだ慣れていない方にも読みにくい部分があるかと思いますが、ある程度触れてましたら、読みやすく感じるかと思います。(私も関数式初心者ですが、すこし慣れてきています)

あと、前述した通り、最後に実行されるほうのeffect の型情報をお見せしたいと思います。

エラー処理、依存性注入前のeffect (最終effect直前)

前に説明した通りに、3つの処理で発生する可能性があるエラーと必要な依存関係を示しています。(今回はAPIErrorしか作成してなくて、それ一つになってますが)

最終的に実行されるeffect

エラーを処理することで、Error Type を なし(never) に変えて、依存性を提供することで、Dependencies を なし(never) にしています

以上で、簡略化とはいえ、業務上に実際にあるちょっと複雑な処理をEffect TSで実装してみました。思ったより長くなりましたが、ここまでご覧になってくださって、ありがとうございます!

検討結果

実装の説明が長くなりましたが、導入の検討でしたので、検討の結果もちゃんと説明していきたいと思います。

メリット

  • 型の安全性が非常に高い、エラーも型情報に入っていますので、常に扱っている変数の型がわかります。TypeScript もその情報でより的確なタイプチェックができます。
  • 開発者の書き方にもよりますが、堅牢性が非常に高くて、開発段階で可能なシナリオをだいたい型情報から認知できます。
  • 制御フローの可視化。従来の catchthrowパターンではなく、エラーを値として扱うことで、処理がすごく離れているcatch block に飛ばされることを防いています。個人個人の好みにもよりますが、私はこちらのほうがやりやすいと思います。

デメリット

  • 習得するのがすごく難しいです。関数式の特性が非常に強くて、ある程度関数式の経験がないと理解しにくいかと思います。その上に、依存性注入も結構組み込まれていますため、それに関しても、一定の知識が必要となります、プラグラミング初心者には向いていないかと思います。
  • アプリ全体がEffect TSで実装されていないとあまり意味がありません。当然のことなんですが、Effect TSが型の安全を保証しているのは Effect の中だけなので、Effect TS以外の処理が混ざっていますと、型安全が完全ではなくなります。
  • コードを堅牢的に書かせるフレームワークなので、コードの堅牢性は高まりますが、開発のコストも比較的に高いかと思います。

結論

結論からいいますと、今のところ、Effect TSの導入はしないかと思います。個人的にはEffect TSのメンタルモデル、型の安全性などが非常に好んでいますが、上述の通りに、チームに導入するにはコストが大きすぎますため、難しいかと思います。チームメンバーが全員興味を持っていて、習得してくれることになっていても既存のアプリにそれを追加するのはコストが高くて、メリットが少ないです。なので、新規のコードベースがあって、チームメンバーがみんなEffect TSで開発したいと思わない限り、Effect TSの導入は難しいかと思います。

とはいえ、私はこの調査を通じて、Effect TSに更に興味を持つようになりましたので、個人開発、小規模の開発などに使っていきたいと考えております。皆さんもこの記事を読んで、興味を持つようになったら、ぜひEffect TSを使ってみてください!

おわりに

10日の記事の担当は エンジニア の小松さんです。お楽しみに。

おまけ

この記事に記載されているコードは私のGitHubにも上げていますので、興味がある方はぜひご覧になってください!

Effect Sample Codeはこちら