mcpField
E
effect-best-practices

effect-best-practices

github.com/makisuo開発79840 回インストール2 週間前に更新
リポジトリ

effect-best-practices について

effect-best-practicesは、型安全性、テスト容易性、可観測性、保守性を重視するEffect-TS開発者向けの規約集です。サービス、エラー、依存関係の合成、Reactの状態管理を扱い、診断とリファクタリングにEffect Language Serverを推奨します。

主な機能

  1. 01Effect.Serviceによるサービス定義
  2. 02TaggedErrorによるエラー設計
  3. 03Layerの依存関係合成ガイド
  4. 04Schemaとブランド型の規約
  5. 05Effect AtomとReactのパターン
  6. 06言語サービスの診断とリファクタリング設定

活用シーン

  1. 01Effect-TSコードベースのパターン統一
  2. 02エラー処理の見直しと改善
  3. 03合成可能なサービス依存レイヤーの構築
  4. 04Reactでの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
機能
  • 診断:Effect 固有の問題を 30 件以上検出します(未処理の Effect、要件の欠落、不正な yield パターンなど)。
  • クイック情報:ホバーすると Effect の型パラメーター(Success、Error、Requirements)を確認できます。
  • 補完:Self、Duration 文字列、Schema のブランドを自動補完します。
  • リファクタリング:async から Effect.gen への変換、Layer の自動合成、Schema への変換ができます。
ビルド時の診断

CI での強制に使用します。

npx effect-language-service patch

設定オプションと CLI ツールについては、references/language-server.md を参照してください。

クイックリファレンス:重要なルール

カテゴリーすることしないこと
サービス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/catchTagscatchAll や mapError
IDSchema.UUID.pipe(Schema.brand("@App/EntityId"))エンティティ ID に通常の string を使う
関数Effect.fn("Service.method")無名ジェネレーター
ロギング構造化データを使う Effect.logconsole.log
設定検証を伴う Config.*process.env を直接使う
オプション両方のケースを処理する Option.matchOption.getOrThrow
Null 許容性ドメイン型で Option<T> を使うnull/undefined
アトムコンポーネントの外で Atom.make を使うレンダリング中にアトムを作成する
アトムの状態グローバル状態には Atom.keepAlive を使う永続状態で keepAlive を指定し忘れる
アトムの更新React コンポーネントで useAtomSet を使うReact から命令的に Atom.update を実行する
アトムのクリーンアップ副作用には get.addFinalizer() を使うイベントリスナーのクリーンアップを忘れる
アトムの結果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 を参照してください。

スキーマとブランド型のパターン

型安全性をサービス境界全体で確保するため、すべてのエンティティ 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)
        // ...
    }
)

レイヤーの合成

依存関係は使用箇所ではなく、サービス内で宣言してください。

// 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,
)

レイヤー合成のパターン:

// 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),
)

Effect.provide よりレイヤーを使う理由:

  • 重複排除:レイヤーは構築をメモ化するため、同じサービスは一度だけインスタンス化されます。Effect.provide は呼び出しごとに新しいインスタンスを作成します。
  • TypeScript のパフォーマンス:Layer.provide を深くネストすると、複雑な再帰型が作られ、LSP の動作が遅くなります。Layer.mergeAll と Layer.provideMerge は、よりフラットな型を生成します。
  • リソース管理:スコープ付きレイヤーでは、リソースを適切に共有し、クリーンアップできます。

レイヤーのテスト、設定に依存するレイヤー、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 は、Effect と連携した React のリアクティブな状態管理を提供します。

基本的な 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)
React のミューテーション

ミューテーション Atom では、useState ではなく result.waiting から読み込み状態を導出してください。

const [result, mutate] = useAtom(deleteMutation, { mode: "promise" })
const isLoading = result.waiting // Updates automatically, no useState/finally needed

ダイアログの責務:ミューテーションのロジックをダイアログコンポーネント内に移してください。ミューテーションフック、読み込み状態、トーストはダイアログ側で管理します。親コンポーネントはデータの props と onSuccess コールバックを渡します。

キャッシュの無効化:ミューテーション後にクエリを自動的に無効化するには、ミューテーション Atom とクエリ Atom の両方で reactivityKeys を使用してください。手動での refresh() 呼び出しに代わる方法です。

ファミリー、localStorage、ミューテーション、アンチパターンなどを含むすべてのパターンについては、references/effect-atom-patterns.md を参照してください。

RPC とクラスターのパターン

RPC コントラクトとクラスターのワークフローについては、以下を参照してください。

  • 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 - 依存関係の合成、レイヤーのテスト
  • rpc-cluster-patterns.md - RpcGroup、Workflow、Activity のパターン
  • effect-atom-patterns.md - Atom、ファミリー、React フック、Result の処理
  • anti-patterns.md - 禁止されているパターンの完全な一覧
  • observability-patterns.md - ロギング、メトリクス、設定のパターン

コメント