mcpField
E
effect-portable-patterns

effect-portable-patterns

github.com/millionco開発3.6k65 回インストール29 週間前に更新
リポジトリ

effect-portable-patterns について

effect-portable-patternsは、TypeScriptでEffectを自己完結型のPromiseユーティリティとして使うためのスキルです。エラー処理、タイムアウト、再試行、キャッシュ、並行処理、パターンマッチ、トレースを扱います。

主な機能

  1. 01自己完結型のEffect関数
  2. 02タグ付きエラーとタグ別処理
  3. 03タイムアウトと再試行スケジュール
  4. 04キャッシュと並行数の制御
  5. 05パターンマッチとトレース

活用シーン

  1. 01非同期呼び出しの失敗処理
  2. 02外部処理へのタイムアウトと再試行の追加
  3. 03結果のキャッシュや並行処理数の制限

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/millionco/expect --skill effect-portable-patterns

インストール後、Claude Code で/effect-portable-patternsを入力して読み込まれたか確認します。

詳細

EffectをポータブルなPromiseユーティリティとして使う

Effectを、Promiseを堅牢に実行するための軽量なユーティリティとして使います。各Effectは自己完結しており(サービス、レイヤー、依存性注入はありません)、境界ではEffect.runPromiseによって通常のPromiseに解決されます。

形は常に、Effect.fnまたはEffect.gen -> パイプ演算子 -> Effect.runPromiseです。

クイックリファレンス

機能API避けるもの
PromiseをラップするEffect.tryPromiseEffect.promise(エラーを握りつぶします)
関数を定義するEffect.fn("name")無名ジェネレーター
エラー_tagを持つData.TaggedError通常のErrorまたはタグなしオブジェクト
タグで捕捉するcatchTag / catchTagscatchAll(型の絞り込みが失われます)
フォールバックorElse、orElseSucceedネストしたtry/catch
タイムアウトEffect.timeout / Effect.timeoutFail手動のAbortController + setTimeout
リトライScheduleを使ったEffect.retry手動のリトライループ
キャッシュEffect.cachedWithTTL / Effect.cachedFunction手動のMapベースのキャッシュ
並行処理{ concurrency: N }を指定したEffect.all手動のPromise.allによる分割処理
パターンマッチMatch.tagを使ったMatch.value / Match.type_tagに対するswitch文
トレースEffect.withSpan / Effect.annotateCurrentSpan手動のconsole.time
境界で実行するEffect.runPromise非同期処理でのEffect.runSync

基本パターン:ポータブルなEffect関数

すべてのEffect関数は次の構造に従います。Effect<Success, Error, never>(要件なし)を構築し、呼び出し元でPromiseとして実行します。

import { Effect, Data } from "effect";

class FetchError extends Data.TaggedError("FetchError")<{
  url: string;
  status: number;
  message: string;
}> {}

const fetchUser = Effect.fn("fetchUser")(function* (userId: string) {
  const response = yield* Effect.tryPromise({
    try: () => fetch(`/api/users/${userId}`),
    catch: () =>
      new FetchError({
        url: `/api/users/${userId}`,
        status: 0,
        message: "Network error",
      }),
  });

  if (!response.ok) {
    return yield* Effect.fail(
      new FetchError({
        url: `/api/users/${userId}`,
        status: response.status,
        message: response.statusText,
      }),
    );
  }

  const user = yield* Effect.tryPromise({
    try: () => response.json() as Promise<User>,
    catch: () =>
      new FetchError({
        url: `/api/users/${userId}`,
        status: response.status,
        message: "Invalid JSON",
      }),
  });

  return user;
});

// At the call site - always resolves to a plain Promise
const user: User = await Effect.runPromise(fetchUser("123"));

タグ付きエラー

Data.TaggedErrorでエラーを定義します。_tagフィールドを使うと、サービスやスキーマなしで型安全にエラーをマッチできます。

import { Data } from "effect";

class TimeoutError extends Data.TaggedError("TimeoutError")<{
  operation: string;
  durationMs: number;
}> {}

class NotFoundError extends Data.TaggedError("NotFoundError")<{
  resource: string;
  id: string;
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  field: string;
  message: string;
}> {}
タグでエラーを捕捉する

単一のタグにはcatchTag、複数のタグにはcatchTagsを使います。どちらも型の絞り込みを維持します。

const result =
  yield *
  fetchUser("123").pipe(
    Effect.catchTag("NotFoundError", (error) =>
      Effect.succeed({ id: error.id, name: "Unknown", fallback: true }),
    ),
    Effect.catchTag("TimeoutError", (error) =>
      Effect.fail(
        new ServiceUnavailableError({
          message: `${error.operation} timed out`,
        }),
      ),
    ),
  );

// Or handle multiple tags at once
const result =
  yield *
  fetchUser("123").pipe(
    Effect.catchTags({
      NotFoundError: (error) => Effect.succeed(defaultUser),
      ValidationError: (error) => Effect.fail(new BadRequestError({ message: error.message })),
    }),
  );

タイムアウト

基本的なタイムアウト(TimeoutExceptionを発生させる)
const result = yield * fetchUser("123").pipe(Effect.timeout("5 seconds"));
カスタムエラーを使ったタイムアウト
const result =
  yield *
  fetchUser("123").pipe(
    Effect.timeoutFail({
      duration: "5 seconds",
      onTimeout: () => new TimeoutError({ operation: "fetchUser", durationMs: 5000 }),
    }),
  );
フォールバック値を使ったタイムアウト
const result =
  yield *
  fetchUser("123").pipe(
    Effect.timeoutTo({
      duration: "5 seconds",
      onSuccess: (user) => user,
      onTimeout: () => defaultUser,
    }),
  );

リトライ

固定回数のリトライ
import { Schedule } from "effect";

const result = yield * fetchUser("123").pipe(Effect.retry({ times: 3 }));
指数バックオフ
const result =
  yield *
  fetchUser("123").pipe(
    Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.compose(Schedule.recurs(5)))),
  );
特定のエラーのみリトライする
const result =
  yield *
  fetchUser("123").pipe(
    Effect.retry({
      times: 3,
      while: (error) => error._tag === "TimeoutError",
    }),
  );
リトライを使い切ったときにフォールバックする
const result =
  yield *
  Effect.retryOrElse(fetchUser("123"), { times: 3 }, (error, fiberId) =>
    Effect.succeed(defaultUser),
  );

タイムアウトとリトライの組み合わせ

const robustFetch = Effect.fn("robustFetch")(function* (userId: string) {
  const user = yield* fetchUser(userId).pipe(
    Effect.timeoutFail({
      duration: "3 seconds",
      onTimeout: () => new TimeoutError({ operation: "fetchUser", durationMs: 3000 }),
    }),
    Effect.retry(Schedule.exponential("200 millis").pipe(Schedule.compose(Schedule.recurs(3)))),
  );
  return user;
});

フォールバック

// Try primary, fall back to secondary on any failure
const result = yield * fetchFromPrimary(id).pipe(Effect.orElse(() => fetchFromSecondary(id)));

// Fall back to a default value
const result = yield * fetchUser(id).pipe(Effect.orElseSucceed(() => defaultUser));

// Remap the error type on failure
const result =
  yield *
  fetchUser(id).pipe(
    Effect.orElseFail(() => new ServiceUnavailableError({ message: "All sources failed" })),
  );

// Try multiple sources, use the first that succeeds
const result =
  yield * Effect.firstSuccessOf([fetchFromCache(id), fetchFromPrimary(id), fetchFromSecondary(id)]);

キャッシュ

TTL付きでEffectをキャッシュする
import { Effect } from "effect";

const cachedConfig = Effect.cachedWithTTL(
  Effect.tryPromise(() => fetch("/api/config").then((r) => r.json())),
  "5 minutes",
);

// Use it - first call fetches, subsequent calls return cached value within TTL
const program = Effect.gen(function* () {
  const getConfig = yield* cachedConfig;
  const config1 = yield* getConfig;
  const config2 = yield* getConfig; // same value, no re-fetch
});
手動で無効化できるキャッシュ
const [getConfig, invalidate] = yield * Effect.cachedInvalidateWithTTL(fetchConfig, "10 minutes");

const config = yield * getConfig;
yield * invalidate; // force re-fetch on next call
引数をキーに関数をメモ化する
const memoizedFetchUser =
  yield *
  Effect.cachedFunction((userId: string) =>
    Effect.tryPromise(() => fetch(`/api/users/${userId}`).then((r) => r.json())),
  );

const user1 = yield * memoizedFetchUser("123"); // fetches
const user2 = yield * memoizedFetchUser("123"); // returns cached
const user3 = yield * memoizedFetchUser("456"); // fetches (different key)

並行処理

並列実行
// Run all effects in parallel (unbounded)
const [users, posts, comments] =
  yield *
  Effect.all([fetchUsers, fetchPosts, fetchComments], {
    concurrency: "unbounded",
  });

// Bounded concurrency (e.g., max 5 at a time)
const results = yield * Effect.all(tasks, { concurrency: 5 });

// Parallel forEach
const enrichedUsers = yield * Effect.forEach(userIds, (id) => fetchUser(id), { concurrency: 10 });
競争(最初に成功したものを採用)
// Race two effects, take the first to complete
const result = yield * Effect.race(fetchFromEast, fetchFromWest);

// Race many effects
const result = yield * Effect.raceAll([fetchFromCache(id), fetchFromDb(id), fetchFromRemote(id)]);

パターンマッチ

タグ付きユニオンやエラー型を網羅的かつ型安全に分岐するには、Matchを使います。

タグ付きエラーのマッチ
import { Match } from "effect";

type ApiError = NotFoundError | TimeoutError | ValidationError;

const describeError = (error: ApiError) =>
  Match.value(error).pipe(
    Match.tag("NotFoundError", (e) => `${e.resource} ${e.id} not found`),
    Match.tag("TimeoutError", (e) => `${e.operation} timed out after ${e.durationMs}ms`),
    Match.tag("ValidationError", (e) => `Invalid ${e.field}: ${e.message}`),
    Match.exhaustive,
  );
型によるマッチ(再利用可能なマッチャー)
const handleResult = Match.type<string | number | boolean>().pipe(
  Match.when(Match.string, (s) => `string: ${s}`),
  Match.when(Match.number, (n) => `number: ${n}`),
  Match.when(Match.boolean, (b) => `boolean: ${b}`),
  Match.exhaustive,
);

handleResult("hello"); // "string: hello"
handleResult(42); // "number: 42"
述語によるマッチ
const categorize = Match.type<{ status: number }>().pipe(
  Match.when({ status: (s) => s >= 500 }, () => "server_error"),
  Match.when({ status: (s) => s >= 400 }, () => "client_error"),
  Match.when({ status: (s) => s >= 200 }, () => "success"),
  Match.orElse(() => "unknown"),
);

トレース

スパンを追加する

Effect.fnは指定された名前でスパンを自動的に作成します。追加のスパンやアノテーションを設定するには、次のようにします。

const fetchAndProcess = Effect.fn("fetchAndProcess")(function* (userId: string) {
  yield* Effect.annotateCurrentSpan("userId", userId);

  const user = yield* fetchUser(userId).pipe(Effect.withSpan("fetchUser"));

  const processed = yield* processUser(user).pipe(Effect.withSpan("processUser"));

  yield* Effect.annotateCurrentSpan("processed", true);
  return processed;
});
Effect内でログを記録する
const program = Effect.fn("program")(function* () {
  yield* Effect.log("Starting operation");
  const result = yield* doWork();
  yield* Effect.log("Operation complete", { resultId: result.id });
  return result;
});

完全な例:堅牢なAPI呼び出し

すべてのパターンを1つのポータブルな関数に組み合わせます。

import { Effect, Data, Schedule, Match } from "effect";

class ApiError extends Data.TaggedError("ApiError")<{
  url: string;
  status: number;
  message: string;
}> {}

class ApiTimeoutError extends Data.TaggedError("ApiTimeoutError")<{
  url: string;
  durationMs: number;
}> {}

const fetchApi = Effect.fn("fetchApi")(function* <T>(url: string) {
  yield* Effect.annotateCurrentSpan("url", url);
  yield* Effect.log("Fetching", { url });

  const response = yield* Effect.tryPromise({
    try: () => fetch(url),
    catch: () => new ApiError({ url, status: 0, message: "Network error" }),
  }).pipe(
    Effect.timeoutFail({
      duration: "10 seconds",
      onTimeout: () => new ApiTimeoutError({ url, durationMs: 10_000 }),
    }),
    Effect.retry(Schedule.exponential("500 millis").pipe(Schedule.compose(Schedule.recurs(3)))),
  );

  if (!response.ok) {
    return yield* Effect.fail(
      new ApiError({
        url,
        status: response.status,
        message: response.statusText,
      }),
    );
  }

  return yield* Effect.tryPromise({
    try: () => response.json() as Promise<T>,
    catch: () => new ApiError({ url, status: response.status, message: "Invalid JSON" }),
  });
});

// Usage at the boundary - resolves to a plain Promise
const data = await Effect.runPromise(
  fetchApi<User>("/api/users/123").pipe(
    Effect.catchTag("ApiTimeoutError", () =>
      Effect.succeed({ id: "123", name: "Unknown" } as User),
    ),
  ),
);

ルール

ルールガイダンス
サービスやレイヤーは使わないR = never を使ってエフェクトを自己完結させてください。effect-best-practices スキルが適用される場合に限り、サービスを使用してください。
常に Effect.fn を使う自動トレーススパンのため、すべての関数に名前を付けてください。
常に Data.TaggedError を使うそれぞれ異なる失敗には、固有のタグ付きエラークラスを用意してください。
常に Effect.tryPromise を使うEffect.promise はエラーを欠陥として握りつぶすため、決して使わないでください。
catchTag を優先する型の絞り込みを維持するため、catchAll よりも catchTag / catchTags を使ってください。
すべてにタイムアウトを設定する外部呼び出しには必ず Effect.timeout または Effect.timeoutFail を設定してください。
境界で実行するEffect.runPromise は、エフェクト関数内ではなく、最も外側の呼び出し箇所で呼び出してください。

参考資料

コメント