关于 effect-best-practices
为 Effect-TS 代码库制定服务、错误、Schema、状态管理和可观测性方面的一致实践,供开发与维护相关项目的团队使用。
主要功能
- 01Effect.Service 与依赖模式
- 02标记错误与显式处理
- 03品牌化 ID 与 Schema 指引
- 04Effect.fn 跟踪与结构化日志
- 05React Atom 状态与结果模式
适用场景
- 01统一 Effect-TS 项目的服务约定
- 02检查错误与 Schema 设计
- 03构建由 Effect 支持的 React 状态
- 04改进跟踪与结构化日志
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ millionco/ expect --skill effect-best-practices安装后在 Claude Code 中输入/effect-best-practices确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/anti-patterns.mdeffect-atom-patterns.mderror-patterns.mdlayer-patterns.mdobservability-patterns.mdrpc-cluster-patterns.mdschema-patterns.mdservice-patterns.md详细介绍
Effect-TS 最佳实践
此技能为 Effect-TS 代码库规范一致且有明确主张的模式。这些模式可优化类型安全性、可测试性、可观测性和可维护性。
快速参考:关键规则
| 类别 | 应该做 | 不要做 |
|---|---|---|
| 服务 | 使用带 accessors: true 的 Effect.Service | 将 Context.Tag 用于业务逻辑 |
| 依赖项 | 在服务中使用 dependencies: [Dep.Default] | 在使用位置手动调用 Layer.provide |
| 错误 | 使用带 message 字段的 Schema.TaggedError | 使用普通类或通用 Error |
| 错误具体性 | 使用 UserNotFoundError、SessionExpiredError | 使用通用的 NotFoundError、BadRequestError |
| 错误处理 | 使用 catchTag/catchTags | 使用 catchAll 或 mapError |
| ID | 使用 Schema.UUID.pipe(Schema.brand("@App/EntityId")) | 将普通 string 用作实体 ID |
| 函数 | 使用 Effect.fn("Service.method") | 使用匿名生成器 |
| 日志记录 | 使用带结构化数据的 Effect.log | 使用 console.log |
| 配置 | 使用带验证的 Config.* | 直接使用 process.env |
| 选项 | 使用包含两种情况的 Option.match | 使用 Option.getOrThrow |
| 可空性 | 在领域类型中使用 Option<T> | 使用 null/undefined |
| Atom | 在组件外使用 Atom.make | 在渲染过程中创建 Atom |
| Atom 状态 | 对全局状态使用 Atom.keepAlive | 忘记为持久状态设置 keepAlive |
| Atom 更新 | 在 React 组件中使用 useAtomSet | 在 React 中命令式调用 Atom.update |
| Atom 清理 | 对副作用使用 get.addFinalizer() | 不清理事件监听器 |
| Atom 结果 | 使用带 onErrorTag 的 Result.builder | 忽略加载/错误状态 |
服务定义模式
始终使用 Effect.Service 来定义业务逻辑服务。它提供自动访问器、内置的 Default 层,以及规范的依赖声明。
import { Effect } from "effect";
export class UserService extends Effect.Service<UserService>()("UserService", {
accessors: true,
dependencies: [UserRepo.Default, CacheService.Default],
effect: Effect.gen(function* () {
const repo = yield* UserRepo;
const cache = yield* CacheService;
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
const cached = yield* cache.get(id);
if (Option.isSome(cached)) return cached.value;
const user = yield* repo.findById(id);
yield* cache.set(id, user);
return user;
});
const create = Effect.fn("UserService.create")(function* (data: CreateUserInput) {
const user = yield* repo.create(data);
yield* Effect.log("User created", { userId: user.id });
return user;
});
return { findById, create };
}),
}) {}
// Usage - dependencies are already wired
const program = Effect.gen(function* () {
const user = yield* UserService.findById(userId);
return user;
});
// At app root
const MainLive = Layer.mergeAll(UserService.Default, OtherService.Default);
适合使用 Context.Tag 的情况:
- 运行时注入的基础设施(Cloudflare KV、worker 绑定)
- 由外部提供资源的工厂模式
详细模式请参阅 references/service-patterns.md。
错误定义模式
始终使用 Schema.TaggedError 来定义错误。这样可以使错误可序列化(RPC 所必需),并提供一致的结构。
import { Schema } from "effect";
import { HttpApiSchema } from "@effect/platform";
export class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
"UserNotFoundError",
{
userId: UserId,
message: Schema.String,
},
HttpApiSchema.annotations({ status: 404 }),
) {}
export class UserCreateError extends Schema.TaggedError<UserCreateError>()(
"UserCreateError",
{
message: Schema.String,
cause: Schema.optional(Schema.String),
},
HttpApiSchema.annotations({ status: 400 }),
) {}
错误处理——使用 catchTag/catchTags:
// CORRECT - preserves type information
yield *
repo.findById(id).pipe(
Effect.catchTag("DatabaseError", (err) =>
Effect.fail(new UserNotFoundError({ userId: id, message: "Lookup failed" })),
),
Effect.catchTag("ConnectionError", (err) =>
Effect.fail(new ServiceUnavailableError({ message: "Database unreachable" })),
),
);
// CORRECT - multiple tags at once
yield *
effect.pipe(
Effect.catchTags({
DatabaseError: (err) =>
Effect.fail(new UserNotFoundError({ userId: id, message: err.message })),
ValidationError: (err) =>
Effect.fail(new InvalidEmailError({ email: input.email, message: err.message })),
}),
);
优先使用明确的错误,而非通用错误
每种不同的失败原因都应有自己的错误类型。 不要将多种失败情况归并为通用 HTTP 错误。
// WRONG - Generic errors lose information
export class NotFoundError extends Schema.TaggedError<NotFoundError>()(
"NotFoundError",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 404 }),
) {}
// Then mapping everything to it:
Effect.catchTags({
UserNotFoundError: (err) => Effect.fail(new NotFoundError({ message: "Not found" })),
ChannelNotFoundError: (err) => Effect.fail(new NotFoundError({ message: "Not found" })),
MessageNotFoundError: (err) => Effect.fail(new NotFoundError({ message: "Not found" })),
});
// Frontend gets useless: { _tag: "NotFoundError", message: "Not found" }
// Which resource? User? Channel? Message? Can't tell!
// CORRECT - Explicit domain errors with rich context
export class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
"UserNotFoundError",
{ userId: UserId, message: Schema.String },
HttpApiSchema.annotations({ status: 404 }),
) {}
export class ChannelNotFoundError extends Schema.TaggedError<ChannelNotFoundError>()(
"ChannelNotFoundError",
{ channelId: ChannelId, message: Schema.String },
HttpApiSchema.annotations({ status: 404 }),
) {}
export class SessionExpiredError extends Schema.TaggedError<SessionExpiredError>()(
"SessionExpiredError",
{
sessionId: SessionId,
expiredAt: Schema.DateTimeUtc,
message: Schema.String,
},
HttpApiSchema.annotations({ status: 401 }),
) {}
// Frontend can now show specific UI:
// - UserNotFoundError → "User doesn't exist"
// - ChannelNotFoundError → "Channel was deleted"
// - SessionExpiredError → "Your session expired. Please log in again."
有关错误映射和重试模式,请参阅 references/error-patterns.md。
Schema 和品牌类型模式
为所有实体 ID 添加品牌类型,确保跨服务边界的类型安全:
import { Schema } from "effect";
// Entity IDs - always branded
export const UserId = Schema.UUID.pipe(Schema.brand("@App/UserId"));
export type UserId = Schema.Schema.Type<typeof UserId>;
export const OrganizationId = Schema.UUID.pipe(Schema.brand("@App/OrganizationId"));
export type OrganizationId = Schema.Schema.Type<typeof OrganizationId>;
// Domain types - use Schema.Struct
export const User = Schema.Struct({
id: UserId,
email: Schema.String,
name: Schema.String,
organizationId: OrganizationId,
createdAt: Schema.DateTimeUtc,
});
export type User = Schema.Schema.Type<typeof User>;
// Input types for mutations
export const CreateUserInput = Schema.Struct({
email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
name: Schema.String.pipe(Schema.minLength(1)),
organizationId: OrganizationId,
});
export type CreateUserInput = Schema.Schema.Type<typeof CreateUserInput>;
不应添加品牌类型的情况:
- 不会跨越服务边界的简单字符串(URL、文件路径)
- 基本配置值
有关转换和高级模式,请参阅 references/schema-patterns.md。
使用 Effect.fn 的函数模式
始终对服务方法使用 Effect.fn。它会使用恰当的跨度名称自动进行追踪:
// CORRECT - Effect.fn with descriptive name
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
yield* Effect.annotateCurrentSpan("userId", id);
const user = yield* repo.findById(id);
return user;
});
// CORRECT - Effect.fn with multiple parameters
const transfer = Effect.fn("AccountService.transfer")(function* (
fromId: AccountId,
toId: AccountId,
amount: number,
) {
yield* Effect.annotateCurrentSpan("fromId", fromId);
yield* Effect.annotateCurrentSpan("toId", toId);
yield* Effect.annotateCurrentSpan("amount", amount);
// ...
});
Layer 组合
在服务中声明依赖项,不要在使用位置声明:
// CORRECT - dependencies in service definition
export class OrderService extends Effect.Service<OrderService>()("OrderService", {
accessors: true,
dependencies: [UserService.Default, ProductService.Default, PaymentService.Default],
effect: Effect.gen(function* () {
const users = yield* UserService;
const products = yield* ProductService;
const payments = yield* PaymentService;
// ...
}),
}) {}
// At app root - simple merge
const AppLive = Layer.mergeAll(
OrderService.Default,
// Infrastructure layers (intentionally not in dependencies)
DatabaseLive,
RedisLive,
);
有关测试层和依赖配置的层,请参阅 references/layer-patterns.md。
Option 处理
绝不要使用 Option.getOrThrow。始终显式处理两种情况:
// CORRECT - explicit handling
yield *
Option.match(maybeUser, {
onNone: () => Effect.fail(new UserNotFoundError({ userId, message: "Not found" })),
onSome: (user) => Effect.succeed(user),
});
// CORRECT - with getOrElse for defaults
const name = Option.getOrElse(maybeName, () => "Anonymous");
// CORRECT - Option.map for transformations
const upperName = Option.map(maybeName, (n) => n.toUpperCase());
Effect Atom(前端状态)
Effect Atom 为 React 提供与 Effect 集成的响应式状态管理。
基本 Atom
import { Atom } from "@effect-atom/atom-react";
// Define atoms OUTSIDE components
const countAtom = Atom.make(0);
// Use keepAlive for global state that should persist
const userPrefsAtom = Atom.make({ theme: "dark" }).pipe(Atom.keepAlive);
// Atom families for per-entity state
const modalAtomFamily = Atom.family((type: string) =>
Atom.make({ isOpen: false }).pipe(Atom.keepAlive),
);
React 集成
import { useAtomValue, useAtomSet, useAtom, useAtomMount } from "@effect-atom/atom-react"
function Counter() {
const count = useAtomValue(countAtom) // Read only
const setCount = useAtomSet(countAtom) // Write only
const [value, setValue] = useAtom(countAtom) // Read + write
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}
// Mount side-effect atoms without reading value
function App() {
useAtomMount(keyboardShortcutsAtom)
return <>{children}</>
}
使用 Result.builder 处理结果
渲染带有效应的 Atom 结果时,使用 Result.builder。它通过 onErrorTag 提供可链式调用的错误处理:
import { Result } from "@effect-atom/atom-react"
function UserProfile() {
const userResult = useAtomValue(userAtom) // Result<User, Error>
return Result.builder(userResult)
.onInitial(() => <div>Loading...</div>)
.onErrorTag("NotFoundError", () => <div>User not found</div>)
.onError((error) => <div>Error: {error.message}</div>)
.onSuccess((user) => <div>Hello, {user.name}</div>)
.render()
}
带副作用的 Atom
const scrollYAtom = Atom.make((get) => {
const onScroll = () => get.setSelf(window.scrollY);
window.addEventListener("scroll", onScroll);
get.addFinalizer(() => window.removeEventListener("scroll", onScroll)); // REQUIRED
return window.scrollY;
}).pipe(Atom.keepAlive);
有关包含 families、localStorage 和反模式的完整模式,请参阅 references/effect-atom-patterns.md。
RPC 和 Cluster 模式
有关 RPC 契约和 Cluster 工作流,请参阅:
references/rpc-cluster-patterns.md- RpcGroup、Workflow.make、Activity 模式
反模式(禁止)
以下模式绝不可接受:
// FORBIDDEN - runSync/runPromise inside services
const result = Effect.runSync(someEffect); // Never do this
// FORBIDDEN - throw inside Effect.gen
yield *
Effect.gen(function* () {
if (bad) throw new Error("No!"); // Use Effect.fail instead
});
// FORBIDDEN - catchAll losing type info
yield * effect.pipe(Effect.catchAll(() => Effect.fail(new GenericError())));
// FORBIDDEN - console.log
console.log("debug"); // Use Effect.log
// FORBIDDEN - process.env directly
const key = process.env.API_KEY; // Use Config.string("API_KEY")
// FORBIDDEN - null/undefined in domain types
type User = { name: string | null }; // Use Option<string>
有关完整列表及其原因,请参阅 references/anti-patterns.md。
可观测性
// Structured logging
yield * Effect.log("Processing order", { orderId, userId, amount });
// Metrics
const orderCounter = Metric.counter("orders_processed");
yield * Metric.increment(orderCounter);
// Config with validation
const config = Config.all({
port: Config.integer("PORT").pipe(Config.withDefault(3000)),
apiKey: Config.secret("API_KEY"),
maxRetries: Config.integer("MAX_RETRIES").pipe(
Config.validate({ message: "Must be positive", validation: (n) => n > 0 }),
),
});
有关指标和追踪模式,请参阅 references/observability-patterns.md。
参考文件
如需详细了解相关模式,请参阅 references/ 目录中的参考文件:
service-patterns.md- 服务定义、Effect.fn、Context.Tag 例外error-patterns.md- Schema.TaggedError、错误重映射、重试模式schema-patterns.md- 品牌类型、转换、Schema.Classlayer-patterns.md- 依赖组合、测试层rpc-cluster-patterns.md- RpcGroup、Workflow、Activity 模式effect-atom-patterns.md- Atom、families、React hooks、Result 处理anti-patterns.md- 禁止模式的完整列表observability-patterns.md- 日志记录、指标、配置模式