Effect TS: TypeScriptにおけるプロフェッショナルなエラー管理とConcurrency

Development tutorial - IT technology blog
Development tutorial - IT technology blog

TypeScriptを長年書いてきた誰もが経験する問題

こんなコードを書いたことはないだろうか?ネストされたtry/catchの連鎖、tryの中のPromise.all、そして外側のcatch——チームの誰もがどのステップでどんなエラーが起きるか確信が持てない状態だ。

async function processOrder(orderId: string) {
  try {
    const order = await fetchOrder(orderId); // ネットワークエラー?
    try {
      const payment = await chargeCard(order); // 支払いエラー?
      await sendEmail(order, payment); // メールエラー?
    } catch (paymentErr) {
      // 個別処理...でもメールエラーもここに入る?
    }
  } catch (err) {
    // errは'unknown'型 — どんな型か不明
    console.error(err);
  }
}

devでは問題なく動くが、productionは違う。errの型はunknown——NetworkErrorなのか、PaymentDeclinedErrorなのか、それとも普通の例外なのか判断できない。TypeScriptコンパイラは完全に沈黙している。エラーが表面化するのは、顧客が問題に直面したときだけだ。

私はEffect TSに移行する前の6ヶ月間、そのようなコードベースを保守していた——その違いはチームが戻りたがらないほど明確だった。

TypeScriptのエラー処理アプローチ比較

振り返ってみると、主なアプローチは4つある——それぞれに固有の痛点がある:

1. 純粋なtry/catch

唯一の利点:何も新しく学ぶ必要がない。しかし代わりに——エラーに型がなく、重要な箇所でcatchを見落としやすく、複数のステップにわたるエラー処理が必要になるとコードはすぐにトレースが難しいカオスな状態になる。

2. Result/Either型(fp-ts、neverthrow)

核心的なアイデア:エラーをreturn typeにencodeする——Result<Value, Error>。コンパイラがproductionで爆発する前にエラー処理を促してくれる。try/catchより明確に優れている。しかし並行処理が必要な場合——3つのリクエストを並列実行して結果をまとめる——コードはすぐに複雑になる。

3. Promise + カスタムエラークラス

従来のJSチームでよく見かけるパターンは、型付きエラーでrejectすることだ:

class PaymentError extends Error {
  constructor(public code: 'declined' | 'invalid_card', message: string) {
    super(message);
  }
}
// しかしPromise<T>はエラーをtype signatureにencodeしない
// TypeScriptは依然としてPaymentErrorのcatchを促さない

依然として「暗黙のエラー」——コンパイラはhandleを強制せず、後でコードを読む人もこの関数が何をthrowするか分からない。

4. Effect TS

Effectは3つの次元をtype signatureにencodeする:Effect<Success, Error, Requirements>。コンパイラはどんなエラーが発生し得るかを正確に把握し、処理を強制する。Concurrency、retry、timeout——すべてコアに統合されており、追加のライブラリは不要だ。

メリット・デメリット分析:Effect TSはあなたのプロジェクトに合っているか?

本当に重要なメリット

  • Typed errorsEffect<Order, NetworkError | PaymentError, never> — signatureを見ればどんなエラーが発生し得るかが即座にわかる。implementationを読む必要がない
  • Composability:EffectをLEGOのように繋げられ、各ステップでエラーを処理するか、制御しながらbubble upできる
  • Concurrency built-inEffect.allEffect.race、fiber-based scheduling——追加ライブラリ不要
  • Resource safetyEffect.acquireReleaseがエラーの有無に関わらずcleanupの実行を保証(GoのdeferやC#のusingと同じ考え方)
  • TestabilityRequirementsを通じたdependency injection——テストでdatabaseをmockするには一つのLayerを差し替えるだけ。複雑なmonkey-patchやjest.mockは不要

使うべきでない場合

  • 小規模なスクリプト、one-offツール——Effectを学ぶコストに見合わない
  • 関数型プログラミングに不慣れなチーム——最初の週のlearning curveはかなり急だ
  • プロジェクトの締め切りが迫っている——スプリント中に新しいパラダイムを学ぶのは避けるべきだ

私がよく勧めるのは:core business logic(payment、注文処理)にはEffectを使い、エラーが起きても影響が少ない小さなutilityにはtry/catchを使い続けることだ。

適切なアプローチの選択

私がよく使う経験則:

  • それぞれ異なる処理が必要な複数種類のエラーがあるロジック → Effect
  • timeout/retryを伴うN個のタスクを並列実行する必要がある場合 → Effect
  • シンプルなCRUD、単一レイヤー、edge caseのエラーが少ない → try/catchまたはneverthrowで十分

最近関わったWebアプリのプロジェクトには5人のdeveloperがいた。core business logicをEffectに移行した後、PRのレビュー時間が約40%削減された——reviewerはfunctionが何をthrowするか知るためにimplementationを読む必要がなくなった。2ヶ月後、未処理のエラーに起因するproduction bugはほぼゼロになった。

Effect TSの実践的な導入

インストール

npm install effect
# または
pnpm add effect

Effectは特別な設定は不要で、strict: trueのTypeScript 5.0+があれば動く。

型付きエラーハンドリング:unknownから具体的な型へ

最初のステップ——エラーをデータとして定義する:

import { Effect, Data } from 'effect';

// エラーをタグ付きデータとして定義
class NetworkError extends Data.TaggedError('NetworkError')<{
  message: string;
  statusCode: number;
}> {}

class PaymentDeclinedError extends Data.TaggedError('PaymentDeclinedError')<{
  reason: 'insufficient_funds' | 'invalid_card' | 'expired';
}> {}

// Function signatureがすべてを語る
const fetchOrder = (id: string): Effect.Effect<Order, NetworkError> =>
  Effect.tryPromise({
    try: () => fetch(`/api/orders/${id}`).then(r => r.json()),
    catch: (err) => new NetworkError({
      message: String(err),
      statusCode: 500
    })
  });

const chargeCard = (order: Order): Effect.Effect<Payment, NetworkError | PaymentDeclinedError> =>
  Effect.tryPromise({
    try: () => paymentGateway.charge(order),
    catch: (err: any) => {
      if (err.code === 'card_declined') {
        return new PaymentDeclinedError({ reason: 'insufficient_funds' });
      }
      return new NetworkError({ message: err.message, statusCode: 500 });
    }
  });

chainと各ステップのエラー処理

const processOrder = (orderId: string) =>
  fetchOrder(orderId).pipe(
    Effect.flatMap(order => chargeCard(order)),
    // ここではPaymentDeclinedErrorのみをhandle
    Effect.catchTag('PaymentDeclinedError', (err) =>
      Effect.logWarning(`Payment declined: ${err.reason}`).pipe(
        Effect.flatMap(() => Effect.fail(err)) // log後に再throwする
      )
    ),
    // NetworkErrorは引き続きcallerにpropagate
  );

// 最上位レイヤーで残りのすべてのエラーを処理
const main = processOrder('order-123').pipe(
  Effect.catchAll((err) => {
    // errはここで正確な型を持つ: NetworkError | PaymentDeclinedError
    switch (err._tag) {
      case 'NetworkError':
        return Effect.logError(`Network issue: ${err.statusCode}`);
      case 'PaymentDeclinedError':
        return Effect.logError(`Payment declined: ${err.reason}`);
    }
  })
);

Effect.runPromise(main);

Concurrency:制御しながら並列実行

ここがEffectがPromise.allより明らかに優れていると感じる点だ:

import { Effect } from 'effect';

// 3つのタスクを並列実行、いずれかが失敗したら即座にfail
const parallel = Effect.all(
  [
    fetchUserProfile(userId),
    fetchUserOrders(userId),
    fetchUserSettings(userId),
  ],
  { concurrency: 'unbounded' } // または制限する: { concurrency: 2 }
);

// Race: 最速のソースから結果を取得
const fromCache = fetchFromCache(key);
const fromDB = fetchFromDatabase(key);
const result = Effect.race(fromCache, fromDB);

// Exponential backoffでリトライ
const withRetry = fetchOrder(id).pipe(
  Effect.retry({
    times: 3,
    schedule: Schedule.exponential('100 millis')
  })
);

// タイムアウト
const withTimeout = fetchOrder(id).pipe(
  Effect.timeout('5 seconds')
);

リソース管理:cleanupを保証する

import { Effect } from 'effect';

// acquireReleaseはエラーの有無に関わらずconnectionが必ず閉じられることを保証
const withDbConnection = Effect.acquireRelease(
  Effect.promise(() => pool.connect()), // 取得
  (conn) => Effect.promise(() => conn.release()) // 解放 — 常に実行
);

const queryUser = (id: string) =>
  Effect.scoped(
    withDbConnection.pipe(
      Effect.flatMap(conn =>
        Effect.promise(() => conn.query('SELECT * FROM users WHERE id = $1', [id]))
      )
    )
  );

実プロジェクトからの実践的なtips

  • 一度にコードベース全体を変換しない:最も重要なモジュール(payment、auth)から始めて、徐々に拡張する。EffectはEffect.promise()とEffect.runPromise()を通じて通常のPromiseと十分な互換性がある。
  • チームがpipe/flatMapに慣れていなければEffect.genを使う:async/awaitに近いsyntaxで、onboardingが容易:
const processOrder = (orderId: string) =>
  Effect.gen(function* () {
    const order = yield* fetchOrder(orderId);
    const payment = yield* chargeCard(order);
    yield* sendConfirmationEmail(order, payment);
    return { order, payment };
  });
  • dependency injectionにはLayer patternを使うContext.Tag + Layerを使ってinterfaceとimplementationを分離——テストにはmockをinject、productionには実実装をinject。別途DIフレームワークは不要。
  • Effectのログを丁寧に確認する:Effectにはstructured logging(Effect.logEffect.logError)がbuilt-inで組み込まれている。console.logの代わりに使うことで、複数のlayerをまたいだエラーのトレースがずっと容易になる。

実用的な結論

Effect TSはすべての問題を解決するわけではない——しかしエラーを明確に処理する必要があり、concurrencyが必須となる本格的なTypeScriptプロジェクトにおいては、従来のtry/catchの最も痛い点を的確に解決する。Type signatureが生きたドキュメントになる——実装やコメントを読まなくても、functionがどんなエラーでfailし得るかが分かる。

最初の週は遅いだろう。3週目には、もう元のやり方には戻りたくなる。

Share: