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避免
封装 PromiseEffect.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"),
);

追踪

添加 Span

Effect.fn 会自动创建一个带指定名称的 Span。若要添加更多 Span 或注解:

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 调用

将所有模式组合到一个可移植函数中:

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使用 catchTag / catchTags 而不是 catchAll,以保留类型收窄。
为所有操作设置超时外部调用始终应使用 Effect.timeout 或 Effect.timeoutFail。
在边界处运行在最外层的调用位置调用 Effect.runPromise,不要在 Effect 函数内部调用。

参考资料

评论

支持 Markdown