effect-best-practices について
effect-best-practicesは、型安全性、テスト容易性、可観測性、保守性を重視するEffect-TS開発者向けの規約集です。サービス、エラー、依存関係の合成、Reactの状態管理を扱い、診断とリファクタリングにEffect Language Serverを推奨します。
主な機能
- 01Effect.Serviceによるサービス定義
- 02TaggedErrorによるエラー設計
- 03Layerの依存関係合成ガイド
- 04Schemaとブランド型の規約
- 05Effect AtomとReactのパターン
- 06言語サービスの診断とリファクタリング設定
活用シーン
- 01Effect-TSコードベースのパターン統一
- 02エラー処理の見直しと改善
- 03合成可能なサービス依存レイヤーの構築
- 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.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
機能
- 診断: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/catchTags | catchAll や mapError |
| ID | Schema.UUID.pipe(Schema.brand("@App/EntityId")) | エンティティ ID に通常の string を使う |
| 関数 | Effect.fn("Service.method") | 無名ジェネレーター |
| ロギング | 構造化データを使う Effect.log | console.log |
| 設定 | 検証を伴う Config.* | process.env を直接使う |
| オプション | 両方のケースを処理する Option.match | Option.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.Classlayer-patterns.md- 依存関係の合成、レイヤーのテストrpc-cluster-patterns.md- RpcGroup、Workflow、Activity のパターンeffect-atom-patterns.md- Atom、ファミリー、React フック、Result の処理anti-patterns.md- 禁止されているパターンの完全な一覧observability-patterns.md- ロギング、メトリクス、設定のパターン