effect-best-practices
npx skills add https://github.com/betalyra/effect-skills --skill effect-best-practices
Agent 安装分布
Skill 文档
Effect-TS Best Practices
This skill enforces opinionated, consistent patterns for Effect-TS codebases. These patterns optimize for type safety, testability, observability, and maintainability.
Core Principles
Effect Type Signature
Effect<Success, Error, Requirements>
// â â â
// | | âââ Dependencies (provided via Layers)
// | âââ Expected errors (typed, must be handled)
// âââ Success value
Data-First Piped Style
ALWAYS prefer data-first pipe style for composition:
// â
GOOD: Data-first with pipe
const result = value.pipe(
Effect.map((n) => n * 2),
Effect.flatMap((n) => processValue(n)),
Effect.catchTag("NetworkError", () => Effect.succeed(fallback)),
);
// â BAD: Function-first style
const result = Effect.catchTag(
Effect.flatMap(
Effect.map(value, (n) => n * 2),
(n) => processValue(n),
),
"NetworkError",
() => Effect.succeed(fallback),
);
“@effect/schema” is deprecated
Don’t try to install nor import from “@effect/schema”, it is deprecated. Instead just import from “effect” package.
// â
GOOD
import { Schema } from "effect";
// â BAD
import { Schema } from "@effect/schema";
Quick Reference: Critical Rules
| Category | DO | DON’T |
|---|---|---|
| Services | Effect.Service |
Context.Tag for business logic |
| Dependencies | dependencies: [Dep.Default] at the top of the service |
Manual Layer.provide at usage sites |
| Errors | Schema.TaggedError with message and cause fields |
Plain classes or generic Error |
| Error Specificity | UserNotFoundError, SessionExpiredError |
Generic NotFoundError, BadRequestError |
| Error Handling | catchTag/catchTags |
catchAll or mapError |
| IDs | Schema.UUID.pipe(Schema.brand("@App/EntityId")) |
Plain string for entity IDs |
| Functions | Effect.fn("Service.method") |
Anonymous generators |
| Logging | Effect.log with structured data |
console.log |
| Config | Config.* with validation |
process.env directly |
| Options | Option.match with both cases |
Option.getOrThrow |
| Nullability | Option<T> in domain types |
null/undefined |
| Atoms | Atom.make outside components |
Creating atoms inside render |
| Atom State | Atom.keepAlive for global state |
Forgetting keepAlive for persistent state |
| Atom Updates | useAtomSet in React components |
Atom.update imperatively from React |
| Atom Cleanup | get.addFinalizer() for side effects |
Missing cleanup for event listeners |
| Atom Results | Result.builder with onErrorTag |
Ignoring loading/error states |
Service Definition Pattern
Always use Effect.Service for business logic services. This provides automatic accessors, built-in Default layer, and proper dependency declaration.
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);
When Context.Tag is acceptable:
- Infrastructure with runtime injection (Cloudflare KV, worker bindings)
- Factory patterns where resources are provided externally
See references/service-patterns.md for detailed patterns.
Error Definition Pattern
Always use Schema.TaggedError for errors. This makes them serializable (required for RPC) and provides consistent structure.
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 }),
) {}
Error handling – use 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 }),
),
}),
);
Prefer Explicit Over Generic Errors
Every distinct failure reason deserves its own error type. Don’t collapse multiple failure modes into generic HTTP errors like NotFoundError or BadRequestError.
UserNotFoundErrorwithuserIdâ Frontend shows “User doesn’t exist”ChannelNotFoundErrorwithchannelIdâ Frontend shows “Channel was deleted”SessionExpiredErrorwithexpiredAtâ Frontend shows “Session expired, please log in”
Generic errors lose context and prevent targeted recovery. See references/error-patterns.md for complete patterns including error remapping and retry strategies.
Schema & Branded Types Pattern
Brand all entity IDs for type safety across service boundaries:
import { Schema } from "effect";
// Entity IDs - always branded
export const UserId = Schema.UUID.pipe(Schema.brand("@App/UserId"));
export type UserId = typeof UserId.Type;
export const OrganizationId = Schema.UUID.pipe(
Schema.brand("@App/OrganizationId"),
);
export type OrganizationId = typeof OrganizationId.Type;
// 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 = typeof User.Type;
// 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 = typeof CreateUserInput.Type;
When NOT to brand:
- Simple strings that don’t cross service boundaries (URLs, file paths)
- Primitive config values
See references/schema-patterns.md for transforms and advanced patterns.
Function Pattern with Effect.fn
Always use Effect.fn for service methods. This provides automatic tracing with proper span names:
// â BAD - arrow function returning a generic effect
const findById = (id: UserId) =>
Effect.gen(function* () {
// ...
});
// â
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 Composition
Declare dependencies in the service, not at usage sites:
// â
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,
);
See references/layer-patterns.md for testing layers and config-dependent layers.
Option Handling
Never use Option.getOrThrow. Always handle both cases explicitly:
// â
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 (Frontend State)
Effect Atom provides reactive state management for React with Effect integration.
Basic Atoms
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 Integration
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}</>
}
Handling Results with Result.builder
Use Result.builder for rendering effectful atom results. It provides chainable error handling with 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()
}
Atoms with Side Effects
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);
See references/effect-atom-patterns.md for complete patterns including families, localStorage, and anti-patterns.
RPC & Cluster Patterns
For RPC contracts and cluster workflows, see:
references/rpc-cluster-patterns.md– RpcGroup, Workflow.make, Activity patterns
Anti-Patterns (Forbidden)
These patterns are never acceptable:
// 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>
See references/anti-patterns.md for the complete list with rationale.
Observability
// 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 }),
),
});
See references/observability-patterns.md for metrics and tracing patterns.
Reference Files
For detailed patterns, consult these reference files in the references/ directory:
service-patterns.md– Service definition, Effect.fn, Context.Tag exceptions, capability-based serviceserror-patterns.md– Schema.TaggedError, error remapping, retry patternsschema-patterns.md– Branded types, transforms, Schema.Classlayer-patterns.md– Dependency composition, testing layers, merge vs providedomain-predicates.md– Equivalence, Order, typeclass-derived predicatesrpc-cluster-patterns.md– RpcGroup, Workflow, Activity patternseffect-atom-patterns.md– Atom, families, React hooks, Result handlinganti-patterns.md– Complete list of forbidden patternsobservability-patterns.md– Logging, metrics, config patternseffect-test-patterns.md– Testing patterns for effect based applications