effect-best-practices について
Effect-TS のサービス設計、エラー、スキーマ、状態管理、可観測性に関する一貫した実践を定めるスキルです。Effect-TS のコードベースを開発・保守するチーム向けです。
主な機能
- 01Effect.Service と依存関係のパターン
- 02タグ付きエラーと明示的な処理
- 03ブランド付き ID とスキーマの指針
- 04Effect.fn によるトレースと構造化ログ
- 05React Atom の状態と結果のパターン
活用シーン
- 01Effect-TS のサービス規約の統一
- 02エラーとスキーマ設計のレビュー
- 03Effect を使った 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")) | エンティティ ID にプレーンな string |
| 関数 | Effect.fn("Service.method") | 無名ジェネレーター |
| ロギング | 構造化データを使った Effect.log | console.log |
| 設定 | 検証を伴う Config.* | process.env を直接使用 |
| オプション | 両方のケースを扱う Option.match | Option.getOrThrow |
| Null 許容性 | ドメイン型での 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、ワーカーバインディング)
- リソースを外部から提供するファクトリーパターン
詳しいパターンについては、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,
);
テスト用レイヤーと設定に依存するレイヤーについては、references/layer-patterns.md を参照してください。
オプションの処理
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による結果の処理
Effect 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);
ファミリー、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.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、ファミリー、Reactフック、Resultの処理anti-patterns.md- 禁止パターンの完全な一覧observability-patterns.md- ロギング、メトリクス、設定のパターン