mcpField
E
effect-best-practices

effect-best-practices

github.com/makisuo开发79840 次安装2 周前更新
代码仓库

关于 effect-best-practices

effect-best-practices 为 Effect-TS 代码库提供一套强调类型安全、可测试性、可观测性和可维护性的规范,面向 Effect 开发者。它涵盖服务、错误、依赖组合及 React 状态管理,并要求使用 Effect Language Server 辅助诊断和重构。

主要功能

  1. 01规范 Effect.Service 服务定义
  2. 02使用 TaggedError 建模错误
  3. 03指导 Layer 依赖组合
  4. 04提供 Schema 与品牌类型约定
  5. 05涵盖 Effect Atom 和 React 模式
  6. 06配置语言服务诊断与重构

适用场景

  1. 01统一 Effect-TS 项目的代码模式
  2. 02检查并改进错误处理方式
  3. 03构建可组合的服务依赖层
  4. 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.md
effect-atom-patterns.md
error-patterns.md
language-server.md
layer-patterns.md
observability-patterns.md
rpc-cluster-patterns.md
schema-patterns.md
service-patterns.md

详细介绍

Effect-TS 最佳实践

本技能为 Effect-TS 代码库规范一致的模式。这些模式旨在提升类型安全性、可测试性、可观测性和可维护性。

Effect Language Server(必需)

Effect Language Server 对 Effect 开发至关重要。 它能在编辑时捕获仅靠 TypeScript 无法检测的错误,提供 Effect 专属重构,并提升开发效率。

设置
  1. 安装:
npm install @effect/language-service --save-dev
  1. 添加到 tsconfig.json:
{
  "compilerOptions": {
    "plugins": [{ "name": "@effect/language-service" }]
  }
}
  1. 将编辑器配置为使用工作区 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.Class
  • layer-patterns.md - 依赖组合、测试 Layer
  • rpc-cluster-patterns.md - RpcGroup、Workflow、Activity 模式
  • effect-atom-patterns.md - Atom、families、React Hooks、Result 处理
  • anti-patterns.md - 禁止模式完整列表
  • observability-patterns.md - 日志记录、指标、配置模式

评论

支持 Markdown