effect-best-practices
Skill79 starsChanged 27 days agoMITView on GitHub
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.
- Reads credentials
- Installs packages
5 other projects use a version of this, 4 of them edited.
Adopted by
Most recently taken up by
- forcedotcom/salesforcedx-vscodeedited, first seen Oct 8, 2026
- millionco/expectedited, first seen Oct 8, 2026
- niieani/caaraword for word, first seen Jul 5, 2026
- jliocsar/parleyedited, first seen May 28, 2026
- betalyra/effect-skillsedited, first seen Apr 13, 2026
Other versions5 projects use a version of this.
Word for word · 1
- niieani/caara★ 4100% same
Edited · 4
- millionco/expect★ 3.6k98% same
- forcedotcom/salesforcedx-vscode★ 1k32% same
- betalyra/effect-skills★ 2070% same
- jliocsar/parley★ 099% same
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository4 other files it gives its agents.
- david-workflowSkill
- effect-reviewSkill
- effect-review-v4Skill
- zero-tech-debtSkill
The file
We split this skill into the rules it gives an agent
3 rules. 1 is common, 0 are distinctive, and 2 are very specific to this project.
1
Common
Most of the 4 edited versions we read keep these.
0
Distinctive
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Common: line 174
Every distinct failure reason deserves its own error type: Don't collapse multiple failure modes into generic HTTP errors.
- Very specific: line 341
Never use
Option.getOrThrow: Always handle both cases explicitly: - Very specific: line 441
Cache invalidation: Use reactivityKeys on both mutation and query atoms to auto-invalidate queries after mutations — replaces manual …
“Common” is measured: kept by at least 6 in 10 of the 4 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Effect-TS Best Practices
This skill enforces opinionated, consistent patterns for Effect-TS codebases. These patterns optimize for type safety, testability, observability, and maintainability.
Effect Language Server (Required)
The Effect Language Server is essential for Effect development. It catches errors at edit-time that TypeScript alone cannot detect, provides Effect-specific refactors, and improves developer productivity.
Setup
- Install:
npm install @effect/language-service --save-dev- Add to
tsconfig.json:
{
"compilerOptions": {
"plugins": [{ "name": "@effect/language-service" }]
}
}- Configure your editor to use workspace TypeScript:
- VSCode: F1 → "TypeScript: Select TypeScript Version" → "Use Workspace Version"
- JetBrains: Settings → Languages & Frameworks → TypeScript → Use workspace version
Features
- Diagnostics: Detects 30+ Effect-specific issues (floating Effects, missing requirements, incorrect yield patterns)
- Quick Info: Hover to see Effect type parameters (Success, Error, Requirements)
- Completions: Auto-complete
Self, Duration strings, Schema brands - Refactors: Convert async → Effect.gen, auto-compose Layers, transform to Schema
Build-Time Diagnostics
For CI enforcement:
npx effect-language-service patchSee references/language-server.md for configuration options and CLI tools.
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 |
| Layers | Layer.mergeAll for flat composition | Deeply nested Layer.provide chains |
| Layer Chaining | Layer.provideMerge for incremental composition | Multiple Layer.provide (creates nested types) |
| Errors | Schema.TaggedError with message field | 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.
// 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."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"
// 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>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:
// 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,
)Layer composition patterns:
// 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),
)Why layers over Effect.provide:
- Deduplication: Layers memoize construction - same service instantiated once.
Effect.providecreates new instances each call. - TypeScript performance: Deep
Layer.providenesting creates complex recursive types that slow the LSP.Layer.mergeAllandLayer.provideMergeproduce flatter types. - Resource management: Scoped layers properly share and clean up resources.
See references/layer-patterns.md for testing layers, config-dependent layers, and the layerConfig pattern.
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)React Mutations
For mutation atoms, derive loading state from result.waiting instead of useState:
const [result, mutate] = useAtom(deleteMutation, { mode: "promise" })
const isLoading = result.waiting // Updates automatically, no useState/finally neededDialog ownership: Move mutation logic into dialog components. Dialog owns the mutation hook, loading state, and toasts. Parent provides data props and an onSuccess callback.
Cache invalidation: Use reactivityKeys on both mutation and query atoms to auto-invalidate queries after mutations — replaces manual refresh() calls.
See references/effect-atom-patterns.md for complete patterns including families, localStorage, mutations, 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:
language-server.md- Effect Language Service setup, diagnostics, refactors, CLI toolsservice-patterns.md- Service definition, Effect.fn, Context.Tag exceptionserror-patterns.md- Schema.TaggedError, error remapping, retry patternsschema-patterns.md- Branded types, transforms, Schema.Classlayer-patterns.md- Dependency composition, testing layersrpc-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 patterns
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

