| name | effect-best-practices |
| description | Enforces Effect-TS patterns for services, errors, layers, and atoms. Use when writing code with Effect.Service, Schema.TaggedError, Layer composition, or effect-atom React components. |
| version | 1.2.0 |
Effect-TS Best Practices
This skill enforces opinionated, consistent patterns for Effect-TS codebases. These patterns optimize for type safety, testability, observability, and maintainability.
For diff/plan review against these patterns, invoke the effect-advocate subagent (.claude/agents/effect-advocate.md).
Effect LS diagnostics (agent usage)
Cursor's read_lints does not surface Effect Language Server diagnostics. Use the CLI:
npx effect-language-service diagnostics --file <path>
npx effect-language-service diagnostics --project tsconfig.json
- Run when editing Effect code; fix reported issues (e.g.
unnecessaryFailYieldableError → yield error directly)
effect-language-service quickfixes shows proposed code changes
Quick Reference: Critical Rules
| Category | DO | DON'T |
|---|
| Services | Effect.Service with accessors: true | Context.Tag for business logic |
| Dependencies | dependencies: [Dep.Default] in service | Manual Layer.provide at usage sites |
| Errors | Schema.TaggedError with message field | Plain classes or generic Error |
| Error Specificity | UserNotFoundError, SessionExpiredError | Generic NotFoundError, BadRequestError |
| Error Handling | catchTag/catchTags; catch only when needed | catchAll; swallowing; catching "just in case" |
| IDs | Schema.UUID.pipe(Schema.brand("@App/EntityId")) | Plain string for entity IDs |
| Functions | Effect.fn over Effect.gen; .gen only for shared pipes | Anonymous generators; .gen for business logic |
| Params vs deps | Params = runtime data; dependencies = yield from context | Passing Ref/PubSub/service as params |
| Naming | FooCommand for commands, domain names for helpers | FooEffect suffix (redundant; TS/Effect.fn already convey type) |
| Logging | Effect.log with structured data | console.log |
| Config | Config.* with validation | process.env directly (except build-time vars like ESBUILD_*) |
| 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 }
}),
}) {}
const program = Effect.gen(function* () {
const user = yield* UserService.findById(userId)
return user
})
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
Params vs Dependencies
- Params = runtime data per call (IDs, user input, per-invocation config)
- Dependencies = shared infrastructure (Ref, PubSub, SubscriptionRef, services) — provide via layer, yield inside the effect
- Build Ref/PubSub/etc in the layer (e.g.
buildAllServicesLayer); consumers yield them, don't receive as params
const createStatusBar = (pubsub: PubSub.PubSub<void>, stateRef: SubscriptionRef.SubscriptionRef<State>) =>
Effect.gen(...)
const PubSubTag = Context.GenericTag<PubSub.PubSub<void>>("PubSub")
const createStatusBar = Effect.gen(function* () {
const pubsub = yield* PubSubTag
const stateRef = yield* StateRefTag
})
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:
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" }))
),
)
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 })),
}),
)
When to Catch (and When Not To)
Most errors surface to the user (message/toast at runtime). Only catch when:
- Genuinely ignore – accept failure and continue (e.g. optional pre-create)
- Better message – default vague; map to clearer domain error
Catch sparingly. No catchAll or "swallow to be safe." Use catchTag/catchTags; log or fail with improved error.
Prefer Explicit Over Generic Errors
Every distinct failure reason deserves its own error type. Don't collapse multiple failure modes into generic HTTP errors.
export class NotFoundError extends Schema.TaggedError<NotFoundError>()(
"NotFoundError",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 404 }),
) {}
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" })),
})
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 }),
) {}
See references/error-patterns.md for error remapping and retry patterns.
Schema & Branded Types Pattern
Brand all entity IDs for type safety across service boundaries:
import { Schema } from "effect"
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>
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>
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>
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: Prefer Effect.fn over Effect.gen
Prefer Effect.fn for effectful code. Provides automatic tracing with proper span names. Span name is required.
Use Effect.gen only when you need a shared effect with common .pipe attached so multiple consumers don't each pipe the same things — e.g. provided dependencies, common error handlers, retries. (Less common with Runtimes.) Service definition bodies are a valid use (shared wiring).
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
yield* Effect.annotateCurrentSpan("userId", id)
const user = yield* repo.findById(id)
return user
})
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)
}
)
const findByIdBad = (id: UserId) =>
Effect.fn("UserService.findById")(function* () {
yield* repo.findById(id)
})
Layer Composition
Declare dependencies in the service, not at usage sites:
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
}),
}) {}
const AppLive = Layer.mergeAll(
OrderService.Default,
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:
yield* Option.match(maybeUser, {
onNone: () => Effect.fail(new UserNotFoundError({ userId, message: "Not found" })),
onSome: (user) => Effect.succeed(user),
})
const name = Option.getOrElse(maybeName, () => "Anonymous")
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"
const countAtom = Atom.make(0)
const userPrefsAtom = Atom.make({ theme: "dark" }).pipe(Atom.keepAlive)
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)
const setCount = useAtomSet(countAtom)
const [value, setValue] = useAtom(countAtom)
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}
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)
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))
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
SubscriptionRef
SubscriptionRef<A> is a mutable ref whose .changes stream always emits the current value as element 0, then all future mutations.
Implemented as (from effect/src/internal/subscriptionRef.ts):
stream.concat(stream.make(currentValue), stream.fromPubSub(pubsub))
The Ref.get + pubsub subscription happen atomically under a semaphore — no events are missed.
Stream.concat(Stream.fromEffect(SubscriptionRef.get(ref)), ref.changes)
Stream.concat(Stream.make(yield* SubscriptionRef.get(ref)), ref.changes)
Stream.merge(Stream.fromEffect(SubscriptionRef.get(ref)), ref.changes)
ref.changes.pipe(...)
To skip the initial snapshot (e.g. avoid a spurious refresh on activation), use Stream.drop(1).
Anti-Patterns (Forbidden)
These patterns are never acceptable:
const result = Effect.runSync(someEffect)
yield* Effect.gen(function* () {
if (bad) throw new Error("No!")
})
yield* effect.pipe(Effect.catchAll(() => Effect.fail(new GenericError())))
yield* effect.pipe(Effect.catchAll(() => Effect.void))
console.log("debug")
const key = process.env.API_KEY
const platform = process.env.ESBUILD_PLATFORM === 'web' ? webImpl : desktopImpl
type User = { name: string | null }
See references/anti-patterns.md for the complete list with rationale.
Observability
yield* Effect.log("Processing order", { orderId, userId, amount })
const orderCounter = Metric.counter("orders_processed")
yield* Metric.increment(orderCounter)
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 })
),
})
See references/observability-patterns.md for metrics and tracing patterns.
Project-Specific Patterns (Apex Language Server)
LSP Logging Integration
Production code: provide EffectLspLoggerLive to bridge Effect logging to LSP workspace/logMessage:
import { EffectLspLoggerLive } from '@salesforce/apex-lsp-parser-ast';
await Effect.runPromise(
myEffect.pipe(Effect.provide(EffectLspLoggerLive))
);
Tests: provide EffectTestLoggerLive instead:
import { EffectTestLoggerLive } from '@salesforce/apex-lsp-parser-ast';
await Effect.runPromise(
myEffect.pipe(Effect.provide(EffectTestLoggerLive))
);
Avoid getLogger() directly in Effect code — use Effect.logDebug / Effect.log / Effect.logWarning / Effect.logError.
Validator Pattern
Validators return Effect<ValidationResult, ValidationError>:
export const MyValidator: Validator = {
id: 'my-validator',
name: 'My Validator',
tier: ValidationTier.IMMEDIATE,
priority: 1,
prerequisites: {
requiredDetailLevel: 'public-api',
requiresReferences: false,
requiresCrossFileResolution: false,
},
validate: (symbolTable, options) =>
Effect.gen(function* () {
const errors: ValidationErrorInfo[] = [];
yield* Effect.logDebug(`MyValidator: found ${errors.length} errors`);
return { errors, warnings: [] };
}),
};
Yielding to Event Loop
For long-running operations, yield to prevent blocking:
import { yieldToEventLoop } from '../utils/effectUtils';
const processLargeList = Effect.gen(function* () {
for (const item of largeList) {
processItem(item);
if (shouldYield) {
yield* yieldToEventLoop;
}
}
});
Reference Files
For detailed patterns, consult these reference files in the references/ directory:
service-patterns.md - Service definition, Effect.fn, Context.Tag exceptions
error-patterns.md - Schema.TaggedError, error remapping, retry patterns
schema-patterns.md - Branded types, transforms, Schema.Class
layer-patterns.md - Dependency composition, testing layers
rpc-cluster-patterns.md - RpcGroup, Workflow, Activity patterns
effect-atom-patterns.md - Atom, families, React hooks, Result handling
anti-patterns.md - Complete list of forbidden patterns
observability-patterns.md - Logging, metrics, config patterns
effect-llm.md - Full Effect developer documentation (LLM-oriented reference)