🪈

あっ、こんなところにEffect-TSが

に公開

みなさん、先日流行っていたこの記事は読みましたか?素晴らしい記事でしたね。

エラーハンドリングをして、パフォーマンスを考慮し、保守性とテスタビリティも担保して、信頼性も踏まえたコードを書こうとすると、どうしてもドメインロジックと副作用が混ざってコードが複雑になってしま、、、いいえ、私たちにはEffectがあります。
https://effect.website/

というわけでEffectの宣伝記事です。元の記事のコードを引用していますが、それを非難する意図はなく(相当きれいなコードだと思います)、Effectで書くとこうなるよという紹介のために引用させていただきます。

元のコード

// エラーハンドリング: Result型で値を型として明示
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type UserFetchError = 'UNAUTHORIZED' | 'RATE_LIMITED' | 'INTERNAL_ERROR';

// 保守性: TypeScriptでAPIレスポンスの型安全性を確保
type UserResponse = {
  id: string;
  name: string;
};

async function getUserNames(
  // パフォーマンス: 複数ユーザーIDをまとめて取得することで、N+1問題を回避
  userIds: string[],
  // テストビリティ: 依存性注入により、モックに差し替え可能に
  deps: Dependencies,
): Promise<Result<Map<string, string>, UserFetchError>> {
  // 信頼性: リトライ機構により、一時的な障害に対応
  const maxRetries = 3;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    // 信頼性: タイムアウトにより、ネットワーク遅延からアプリケーションを保護
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 5000);

    try {
      const response = await deps.fetchClient(
        // スケーラビリティ: バッチ処理でリクエスト回数を最小化
        // 保守性: URLのベタ書きを排除
        `${deps.apiBaseUrl}/users/batch`,
        {
          method: 'POST',
          headers: {
            // 認証・認可: JWTでAPIを保護
            Authorization: `Bearer ${await deps.jwtProvider()}`,
            'Content-Type': 'application/json',
          },
          // パフォーマンス: 複数ユーザーIDをまとめて取得することで、N+1問題を回避
          // パフォーマンス: 必要なフィールドのみ取得してデータ転送量を最小化
          body: JSON.stringify({ ids: userIds, fields: ['id', 'name'] }),
          signal: controller.signal,
        },
      );

      // エラーハンドリング: HTTPステータスを適切にハンドリング
      if (!response.ok) {
        if (response.status >= 400 && response.status < 500) {
          switch (response.status) {
            // 認証・認可: APIを保護
            case 403:
              throw new NonRetryableError('UNAUTHORIZED', response.status);
            // スケーラビリティ: レート制限対応
            case 429:
              throw new NonRetryableError('RATE_LIMITED', response.status);
            default:
              throw new NonRetryableError('INTERNAL_ERROR', response.status);
          }
        }
        throw response;
      }

      const users = await response.json();

      // セキュリティ: レスポンスを厳密に検証
      if (!isValidUsersResponse(users)) {
        throw new NonRetryableError('INTERNAL_ERROR');
      }

      const results = new Map(users.map((user) => [user.id, user.name]));
      return { ok: true, value: results };
    } catch (error) {
      // 可観測性: 構造化されたログ
      deps.logger.error('Failed to fetch users', {
        userIds,
        attempt,
        error,
      });
      // 可観測性: メトリクス
      deps.metrics.increment('user_fetch_error', {
        status: error instanceof Response || error instanceof NonRetryableError ? error.status : undefined,
      });

      if (error instanceof NonRetryableError) {
        return { ok: false, error: error.errorType };
      }

      if (attempt < maxRetries) {
        // 信頼性: 指数バックオフ(1秒→2秒→4秒)によるリトライで過負荷を防止
        const delay = 2 ** attempt * 1000;
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    } finally {
      clearTimeout(timeoutId);
    }
  }

  return { ok: false, error: 'INTERNAL_ERROR' };
}

Effectを使うとこのような形で書けます。

const getUserNamesBatch = (
  fetchClient: typeof fetch,
  apiBaseUrl: string,
  token: string,
  userIds: string[],
  signal: AbortSignal
): Effect.Effect<Response, NonRetryableError | unknown, never> =>
  Effect.tryPromise({
    try: () =>fetchClient(`${apiBaseUrl}/users/batch`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ ids: userIds, fields: ['id', 'name'] }),
      signal
  }),catch: (e) => Effect.fail(e) 
}).pipe(
    Effect.flatMap((response) => {
      if (!response.ok) {        
        switch (response.status) {
          case 403:
            return Effect.fail(new NonRetryableError('UNAUTHORIZED', 403));
          case 429:
            return Effect.fail(new NonRetryableError('RATE_LIMITED', 429));
          default:
            return Effect.fail(new NonRetryableError('INTERNAL_ERROR', response.status));
        }
      }
      return Effect.succeed(response); 
    })
  );

function withRetryAndTimeout<S, E, R>(
  coreEffect: Effect.Effect<S, E, R>,
  controller: AbortController,
  timeoutMs: number = 5000,
  maxRetries: number = 5,
  retrySchedule = Schedule.exponential(Duration.seconds(1)).pipe(Schedule.upTo(maxRetries))
): Effect.Effect<S , E | UserFetchError |'INTERNAL_ERROR', R | WrapperDependencies> {    
  return Effect.gen(function* () {
    const timeout = setTimeout(() => controller.abort(), timeoutMs);

    const handled = coreEffect.pipe(
      Effect.retry({
        schedule: retrySchedule,
        until: (error) => error instanceof NonRetryableError,
      }),
      Effect.catchAll((error) =>
        Effect.gen(function* () {
          const { logger, metrics } = yield* WrapperDependencies;

          logger.error('Failed to fetch', { error });
          metrics.increment('fetch_error', {
            status: error instanceof NonRetryableError ? error["status"] : undefined,
          });

          if (error instanceof NonRetryableError) {
            return yield* Effect.fail(error.errorType);
          }
          return yield* Effect.fail<'INTERNAL_ERROR'>('INTERNAL_ERROR');
        })
      )
    );

    try {
      return yield* handled;
    } finally {
      clearTimeout(timeout);
    }
  });
}


const getUserNamesCore = (userIds: string[], signal: AbortSignal) => Effect.gen(function* () {
    const { fetchClient, apiBaseUrl, jwtProvider } = yield* CoreDependencies;  
    const token = yield* Effect.promise(() => jwtProvider());
    const response = yield* getUserNamesBatch(fetchClient, apiBaseUrl, token, userIds, signal);
    const json = yield* Effect.promise(() => response.json());
    const parsed = yield* EffectSchema.decode(UserResponseSchema)(json);
    return new Map(parsed.map((u) => [u.id, u.name]));
});
    

同様のコードを書いてみるとこのような感じでしょうか。リトライ、タイムアウト、ロギング、メトリクスを再利用可能な形で抜き出すことができました。

呼び出す時はこんな感じ。

const userIds = ['id1', 'id2'];
const ac = new AbortController();
const userNameEffect = withRetryAndTimeout(getUserNamesCore(userIds, ac.signal),ac);
Effect.runPromise(
  userNamesEffect.pipe(Effect.provide(coreLayer), Effect.provide(wrapperLayer))
)

注目すべきは中心となるロジック(getUserNamesCore)は8行に収めることができた点でしょうか。
元の5行よりは長くなりましたが、いろいろな機能を足した上でもシンプルに保つことができています。
(行数はメソッドの切り出し方や書き方次第でもっと短くも長くもなりますが、関心のあるドメインロジックだけを抜き出せる、という主張です。)

もう一つ良い点を挙げるなら、元のコードはきれいに書かれていて読むのは簡単ですが、ゼロから書こうと思うとかなりスキルを要するし、人によって書き方の違いが大きくでると思われる一方で、Effectのコードはドメインロジックの部分は大体共通になるでしょうしwithRetryAndTimeoutを共通化しておけば大体同じようなコードになると思われる点です。

この記事を読んでEffectに興味を持った方はぜひ試してみてください。

Discussion