关于 effect-best-practices
effect-best-practices 为 Effect-TS 代码库提供一套强调类型安全、可测试性、可观测性和可维护性的规范,面向 Effect 开发者。它涵盖服务、错误、依赖组合及 React 状态管理,并要求使用 Effect Language Server 辅助诊断和重构。
主要功能
- 01规范 Effect.Service 服务定义
- 02使用 TaggedError 建模错误
- 03指导 Layer 依赖组合
- 04提供 Schema 与品牌类型约定
- 05涵盖 Effect Atom 和 React 模式
- 06配置语言服务诊断与重构
适用场景
- 01统一 Effect-TS 项目的代码模式
- 02检查并改进错误处理方式
- 03构建可组合的服务依赖层
- 04在 React 中管理 Effect Atom 状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ makisuo/ skills --skill effect-best-practices安装后在 Claude Code 中输入/effect-best-practices确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/anti-patterns.mdeffect-atom-patterns.mderror-patterns.mdlanguage-server.mdlayer-patterns.mdobservability-patterns.mdrpc-cluster-patterns.mdschema-patterns.mdservice-patterns.md详细介绍
Effect-TS 最佳实践
本技能为 Effect-TS 代码库规范一致的模式。这些模式旨在提升类型安全性、可测试性、可观测性和可维护性。
Effect Language Server(必需)
Effect Language Server 对 Effect 开发至关重要。 它能在编辑时捕获仅靠 TypeScript 无法检测的错误,提供 Effect 专属重构,并提升开发效率。
设置
- 安装:
npm install @effect/language-service --save-dev
- 添加到
tsconfig.json:
{
"compilerOptions": {
"plugins": [{ "name": "@effect/language-service" }]
}
}
- 将编辑器配置为使用工作区 TypeScript:
- VSCode:F1 → “TypeScript: Select TypeScript Version” → “Use Workspace Version”
- JetBrains:Settings → Languages & Frameworks → TypeScript → Use workspace version
功能
- 诊断:检测 30 多种 Effect 专属问题(未处理的 Effect、缺失的依赖项、错误的 yield 模式)
- 快速信息:悬停即可查看 Effect 类型参数(成功值、错误、依赖项)
- 补全:自动补全
Self、Duration 字符串、Schema 品牌类型 - 重构:将 async 转换为 Effect.gen、自动组合 Layer、转换为 Schema
构建时诊断
用于 CI 强制检查:
npx effect-language-service patch
请参阅 references/language-server.md,了解配置选项和 CLI 工具。
快速参考:关键规则
| 类别 | 应该 | 不应该 |
|---|---|---|
| 服务 | 使用带有 accessors: true 的 Effect.Service | 将 Context.Tag 用于业务逻辑 |
| 依赖项 | 在服务中使用 dependencies: [Dep.Default] | 在使用位置手动调用 Layer.provide |
| Layer | 使用 Layer.mergeAll 进行扁平组合 | 使用深层嵌套的 Layer.provide 链 |
| Layer 链接 | 使用 Layer.provideMerge 进行增量组合 | 多次调用 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 Layer,并能正确声明依赖项。
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,
)
Layer 组合模式:
// Use Layer.mergeAll for flat composition of same-level layers
const RepoLive = Layer.mergeAll(
UserRepo.Default,
OrderRepo.Default,
ProductRepo.Default,
)
// Use Layer.provideMerge for incremental chaining (flatter types than Layer.provide)
const MainLive = DatabaseLive.pipe(
Layer.provideMerge(ConfigServiceLive),
Layer.provideMerge(LoggerLive),
Layer.provideMerge(CacheLive),
)
使用 Layer 而不是 Effect.provide 的原因:
- 去重:Layer 会记忆构造过程,同一服务只会实例化一次。
Effect.provide每次调用都会创建新实例。 - TypeScript 性能:深层嵌套的
Layer.provide会创建复杂的递归类型,拖慢 LSP。Layer.mergeAll和Layer.provideMerge生成的类型更扁平。 - 资源管理:作用域 Layer 能正确共享并清理资源。
有关测试 Layer、依赖配置的 Layer 和 layerConfig 模式,请参阅 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 处理结果
使用 Result.builder 渲染有效果的 Atom 结果。它通过 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)
React 变更
对于变更 Atom,请根据 result.waiting 派生加载状态,而不要使用 useState:
const [result, mutate] = useAtom(deleteMutation, { mode: "promise" })
const isLoading = result.waiting // Updates automatically, no useState/finally needed
对话框所有权: 将变更逻辑移入对话框组件。对话框负责变更 Hook、加载状态和提示。父组件提供数据属性和 onSuccess 回调。
缓存失效: 在变更 Atom 和查询 Atom 上使用 reactivityKeys,以便在变更后自动使查询失效——替代手动调用 refresh()。
有关包括 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.redacted("API_KEY"),
maxRetries: Config.integer("MAX_RETRIES").pipe(
Config.validate({ message: "Must be positive", validation: (n) => n > 0 })
),
})
有关指标和追踪模式,请参阅 references/observability-patterns.md。
参考文件
有关详细模式,请参阅 references/ 目录中的以下参考文件:
language-server.md- Effect Language Service 设置、诊断、重构、CLI 工具service-patterns.md- 服务定义、Effect.fn、Context.Tag 例外情况error-patterns.md- Schema.TaggedError、错误映射、重试模式schema-patterns.md- 品牌类型、转换、Schema.Classlayer-patterns.md- 依赖组合、测试 Layerrpc-cluster-patterns.md- RpcGroup、Workflow、Activity 模式effect-atom-patterns.md- Atom、families、React Hooks、Result 处理anti-patterns.md- 禁止模式完整列表observability-patterns.md- 日志记录、指标、配置模式