effect-portable-patterns について
effect-portable-patternsは、TypeScriptでEffectを自己完結型のPromiseユーティリティとして使うためのスキルです。エラー処理、タイムアウト、再試行、キャッシュ、並行処理、パターンマッチ、トレースを扱います。
主な機能
- 01自己完結型のEffect関数
- 02タグ付きエラーとタグ別処理
- 03タイムアウトと再試行スケジュール
- 04キャッシュと並行数の制御
- 05パターンマッチとトレース
活用シーン
- 01非同期呼び出しの失敗処理
- 02外部処理へのタイムアウトと再試行の追加
- 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.tryPromise | Effect.promise(エラーを握りつぶします) |
| 関数を定義する | Effect.fn("name") | 無名ジェネレーター |
| エラー | _tagを持つData.TaggedError | 通常のErrorまたはタグなしオブジェクト |
| タグで捕捉する | catchTag / catchTags | catchAll(型の絞り込みが失われます) |
| フォールバック | 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 は、エフェクト関数内ではなく、最も外側の呼び出し箇所で呼び出してください。 |
参考資料
コメント
Markdown 対応