--- name: effect-best-practices description: Enforces Effect-TS patterns for services, errors, layers, atoms, and Effect.pipe composition. Use when writing Effect.Service, Schema.TaggedError, Layer, effect-atom, Effect.fn/`.pipe`, or `yield*` pipelines. review: always version: 1.6.1 --- 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: ```bash npx effect-language-service diagnostics --file # or whole project: npx effect-language-service diagnostics --project tsconfig.json ``` - The PostToolUse `verify-on-edit.sh` hook auto-runs `--file ` on every `.ts` Edit/Write and surfaces output as `followup_message`. Address what it reports. - **Address warnings AND messages, not just errors.** `references/diagnostics-findings.md` maps each common finding to its fix; `config/effect-diagnostics.json` `enforcedRules` is the build gate. - Enforcing a rule takes two edits, not just an `enforcedRules` entry — see `references/diagnostics-findings.md`. - After a batch of edits, run `--project tsconfig.json` for the affected package to catch cross-file issues. - `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 | Split tags when catch arms or field shapes differ; telemetry dimensions are fields on one tag | Extra tags that all print `message` / fire the same span | | Error Handling | `catchTag`/`catchTags`; catch only when needed | `catchAll`; swallowing; catching "just in case" | | IDs | Salesforce record/org: `SalesforceId`/`OrgId` (`core/schemas/salesforceId.ts`). `DefaultOrgInfoSchema.orgId`/`devHubOrgId`: `Schema.optional(OrgId)` like `cliId`. Else `Schema.UUID.pipe(Schema.brand("@App/EntityId"))` | Plain `string`; `getAuthInfoFields().orgId` ad hoc; `optionalWith` as Option on DefaultOrgInfo | | Functions | `Effect.fn` over `Effect.gen`; `.gen` only for shared pipes | Anonymous generators; nested `Effect.gen` to attach recovery; `.gen` for business logic | | Composition | `.pipe`; `const` only if read ≥2×. Details: `references/composition-style.md` | single-use `const x = yield*` then `f(x)` | | 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_*`) | | Time values | `Duration.seconds(30)`, `Duration.millis(5000)`; params as `Duration.DurationInput`. Enforced by `local/no-raw-duration` for a numeric arg/prop in `Duration` / `DurationInput` position only. `bigint`, template strings, `number` params, and `TIMEOUT_MS` constants stay outside the rule. | Numeric milliseconds as `number` params or `TIMEOUT_MS = 30_000` constants | | Options | `Option.match` with both cases | `Option.getOrThrow` | | Nullability | `Option` 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 | | Resource Cleanup | Scoped service/layer + `Effect.addFinalizer` | Returning `dispose`; delegating Effect-owned resources to callers | | Atom Results | `Result.builder` with `onErrorTag` | Ignoring loading/error states | | Grouping | `Arr.groupBy` (effect/Array) | `Object.groupBy`, whose `Partial` forces a filter | ## Service Definition Pattern **Always use `Effect.Service`** for business logic services. This provides automatic accessors, built-in `Default` layer, and proper dependency declaration. ```typescript import { Effect } from 'effect'; export class UserService extends Effect.Service()('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 = UserService.findById(userId); // 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 - Interfaces with caller-provided implementations — no single canonical one to bundle as `.Default` (e.g. `SoqlBuilderService`, implemented once by the VS Code host and once by a test fake); see `references/service-patterns.md` ### 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 ```typescript // WRONG - passing shared infra as params const createStatusBar = (pubsub: PubSub.PubSub, stateRef: SubscriptionRef.SubscriptionRef) => Effect.gen(...) // Caller must create and pass; wiring scattered at call sites // CORRECT - yield inside, build in layer const PubSubTag = Context.GenericTag>("PubSub") const createStatusBar = Effect.gen(function* () { const pubsub = yield* PubSubTag const stateRef = yield* StateRefTag // ... }) // Layer: Layer.effect(PubSubTag, PubSub.sliding(1)) ``` 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. ```typescript import { Schema } from 'effect'; import { HttpApiSchema } from '@effect/platform'; export class UserNotFoundError extends Schema.TaggedError()( 'UserNotFoundError', { userId: UserId, message: Schema.String }, HttpApiSchema.annotations({ status: 404 }) ) {} export class UserCreateError extends Schema.TaggedError()( 'UserCreateError', { message: Schema.String, cause: Schema.optional(Schema.String) }, HttpApiSchema.annotations({ status: 400 }) ) {} ``` **Error handling - use `catchTag`/`catchTags`:** ```typescript // 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 })) }) ); ``` ### 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 Expected skip (missing optional plugin, incompatible version): write nls on the success path (guard / Match branch). `fail`+`catchTag` is for unexpected recovery — a handler that isn't "print this string." Catch sparingly. No `catchAll` or "swallow to be safe." Use `catchTag`/`catchTags`; log or fail with improved error. ### Prefer Explicit Over Generic Errors Split tags when **catch arms or field shapes** differ — e.g. `message`+`cause` vs `message`+`cause`+`setting`. Same catch work (print `message`, one span) → one tag; telemetry dimensions are fields, not tags. nls variance lives in `message`. Frontend/RPC still splits when the UI branches (`UserNotFoundError` vs `ChannelNotFoundError`). See `references/error-patterns.md`. ### Accumulating Errors Across a Collection To **continue past failures** instead of short-circuiting on the first, don't hand-roll `Either` + `catchTag` + a re-loop. Use `Effect.partition` (both buckets), `Effect.validateAll` (all-or-nothing), or `Effect.validateFirst`. These recover the typed error channel per item but do NOT capture interruption — so a Cancel still aborts the whole loop. See `references/error-patterns.md` for the accumulation/interruption nuance, error remapping, and retry patterns. ## Schema & Branded Types Pattern **Brand all entity IDs** for type safety across service boundaries. This repo — Salesforce record/org ids are not UUIDs. Use `SalesforceId`/`OrgId` and `orgIdFrom`/`orgIdFromConnection` (`getFields()` / Connection; `Option`; `references/schema-patterns.md`). Other AuthFields: `authFieldsFrom`/`authFieldsFromConnection`. `DefaultOrgInfoSchema.orgId`/`devHubOrgId`: `Schema.optional(OrgId)` like `cliId` — not Option. ```typescript import { Schema } from 'effect'; // Entity IDs - always branded export const UserId = Schema.UUID.pipe(Schema.brand('@App/UserId')); export type UserId = Schema.Schema.Type; export const TenantId = Schema.UUID.pipe(Schema.brand('@App/TenantId')); export type TenantId = Schema.Schema.Type; // Domain types - use Schema.Struct export const User = Schema.Struct({ id: UserId, email: Schema.String, name: Schema.String, tenantId: TenantId, createdAt: Schema.DateTimeUtc }); export type User = Schema.Schema.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)), tenantId: TenantId }); export type CreateUserInput = Schema.Schema.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: Prefer Effect.fn over Effect.gen **Prefer `Effect.fn`** for effectful code. Provides automatic tracing with proper span names. Span name required; enforced by `local/require-effect-fn-span-name`. `Effect.tap` / `*SuccessNotification` middleware before catch middleware: `local/effect-fn-catch-middleware-last` (later catch and non-success guards stay valid). Details: services-extension-consumption Success handling. **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). ```typescript // CORRECT - Effect.fn with descriptive name const findById = Effect.fn('UserService.findById')(function* (id: UserId) { yield* Effect.annotateCurrentSpan('userId', id); return yield* repo.findById(id); }); // 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); // ... }); // WRONG - params on wrapper arrow, generator has none (closure capture) // Enforced by local/no-effect-fn-wrapper const findByIdBad = (id: UserId) => Effect.fn('UserService.findById')(function* () { yield* repo.findById(id); // id from closure }); // WRONG - Effect.fn invoked immediately (config-enforced effectFnIife). Effect.fn builds a reusable // function; for one-shot use write Effect.gen and keep the span with a piped withSpan. const opened = Effect.fn('FsService.open')(function* () { yield* fs.showTextDocument(uri); })(); // CORRECT const openedOk = Effect.gen(function* () { yield* fs.showTextDocument(uri); }).pipe(Effect.withSpan('FsService.open')); // Naming: Don't append Effect. For commands use FooCommand; for helpers/lifecycle use domain names. // WRONG: logGetEffect, executeAnonymousDocumentEffect, activateEffect // CORRECT: logGetCommand, executeAnonymousCommand, executeAnonymous (helper), activation (lifecycle) ``` See `references/composition-style.md`: `.pipe`, `const` only if read ≥2×, terminal runner, point-free safety, Match dispatch, guard clauses, recovery on a subsequence. ## Layer Composition **Declare dependencies in the service**, not at usage sites: ```typescript // CORRECT - dependencies in service definition export class OrderService extends Effect.Service()('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. ### Effect-Owned Resources Resources created inside an Effect service/layer belong to its scope. Prefer `scoped` plus `Effect.addFinalizer` or `Effect.acquireRelease`; don't expose `dispose` or delegate cleanup to a host lifecycle. ```typescript export class StatusService extends Effect.Service()('StatusService', { accessors: true, scoped: Effect.gen(function* () { const item = vscode.window.createStatusBarItem(); yield* Effect.addFinalizer(() => Effect.sync(() => item.dispose())); return { show: Effect.sync(() => item.show()) }; }) }) {} ``` The layer owner must close its scope. A `ManagedRuntime` owns its layers' scopes; dispose it during extension deactivation. Use `context.subscriptions` only for resources created outside Effect ownership. ## Option Handling **Never use `Option.getOrThrow`**. Always handle both cases explicitly: ```typescript // 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) Reactive React state via `@effect-atom/atom-react`. Define atoms OUTSIDE components; `keepAlive` for state that must persist; `useAtomSet` to write; `Result.builder` to render effectful results; `get.addFinalizer` to clean up listeners. ```typescript import { Atom, Result, useAtomValue, useAtomSet } from '@effect-atom/atom-react'; const countAtom = Atom.make(0); // outside the component const prefsAtom = Atom.make({ theme: 'dark' }).pipe(Atom.keepAlive); // persistent function Counter() { const count = useAtomValue(countAtom); const setCount = useAtomSet(countAtom); return ; } // Effectful atom → Result; handle loading/error/success function UserProfile() { return Result.builder(useAtomValue(userAtom)) .onInitial(() =>
Loading...
) .onErrorTag('NotFoundError', () =>
User not found
) .onError(error =>
Error: {error.message}
) .onSuccess(user =>
Hello, {user.name}
) .render(); } ``` ## SubscriptionRef `SubscriptionRef` 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`): ```ts stream.concat(stream.make(currentValue), stream.fromPubSub(pubsub)) ``` The `Ref.get` + pubsub subscription happen atomically under a semaphore — no events are missed. ```typescript // WRONG — prepended get is always redundant 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) // CORRECT — .changes already provides the snapshot ref.changes.pipe(...) ``` To skip the initial snapshot (e.g. avoid a spurious refresh on activation), use `Stream.drop(1)`. ## Grouping `Object.groupBy` is typed `Partial>`, so every consumer filters or defaults the `undefined`. `Arr.groupBy` returns `Record>` — total, so `Object.entries` needs no guard. ```typescript import * as Arr from 'effect/Array'; const grouped = Arr.groupBy(ids, id => id.slice(0, 3)); // Record> Object.entries(grouped).map(([prefix, group]) => query(prefix, group)); // vs Object.groupBy, where the same line needs: // .filter((entry): entry is [string, string[]] => isNotUndefined(entry[1])) ``` Keep `Object.groupBy` only when the `Partial` is load-bearing — e.g. destructuring absent keys with defaults, `const { deploys = [], deleted = [] } = ...`. ## Anti-Patterns (Forbidden) The DON'T column above names each forbidden pattern. `references/anti-patterns.md` has the complete list — each with rationale and the correct alternative. ## Emptiness Guards: match the declared type `== null` / `!= null` are banned in effect packages (`noNullCompare` in `eslint.config.mjs`). Pick the `effect/Predicate` guard by what the declared type actually admits — one guard per union shape, no exceptions: | declared type | guard | | ---------------------- | -------------------------- | | `T \| undefined` | `isUndefined` / `isNotUndefined` | | `T \| null` | `isNull` / `isNotNull` | | `T \| null \| undefined` | `isNullable` / `isNotNullable` | ```typescript import * as Schema from 'effect/Schema'; import { isNotNull, isNotUndefined, isNullable, isUndefined } from 'effect/Predicate'; // T | undefined — the common case (Optional, optional props, ?? sources) if (isUndefined(maybeValue)) return; Effect.filterOrFail(isNotUndefined, () => new NotFoundError({ message: '...' })); // non-id string that must be non-blank — not `isNotUndefined && length > 0` Effect.filterOrFail(Schema.is(Schema.NonEmptyString), () => new NotFoundError({ message: '...' })); // AuthFields org id: `orgIdFrom` / `orgIdFromConnection` → `Option`; fail-if-missing via `Option.match` // DefaultOrgInfo orgId: already `OrgId | undefined` (`Schema.optional(OrgId)` like cliId) // T | null — e.g. RegExp.exec, JSON payload fields const match = scriptRegex.exec(html); if (isNotNull(match)) { … } // T | null | undefined — e.g. `exclude?: vscode.GlobPattern | null` (optional AND nullable) const arr = isNullable(exclude) ? undefined : [exclude]; ``` Runtime semantics: `isUndefined` is `x === undefined`, `isNull` is `x === null`, `isNullable` is either (`node_modules/effect/src/Predicate.ts:611,649,925`). A wider guard than the type needs still compiles — it just implies a case the type can't produce, so it reads as a lie about the value. Note `typeof x === 'object' && x !== null` object-narrowing stays as-is; that's a structural check, not an emptiness check. ## Point-free Predicates in Filters Use `Predicate.not()` for filter negations; combines point-free and readability. ```typescript import { not } from 'effect/Predicate'; // CORRECT — point-free negation arr.filter(not(isFlowTest)) // AVOID — arrow wrapper around negation arr.filter(x => !isFlowTest(x)) ``` Works with any predicate — built-in (`isString`, `isError`, `isNotUndefined`, `isNotNullable`) or custom. Only the wrapped predicate can be receiver-sensitive: `not(obj.method)` detaches the receiver, so verify the impl uses no `this` first (see [composition-style](./references/composition-style.md#point-free-terminal-step-safe-only-when-impl-doesnt-use-this)). Keep `!` where the predicate isn't element-level (`!isCollectionType(decl.type)`) unless a named element predicate already exists or is worth adding. ## Imports: Prefer Deep Imports from @effect/platform Barrel imports from `@effect/platform` bundle HttpApiSwagger (Swagger UI), which esbuild cannot tree-shake. This bloats web/desktop bundles by ~5.5MB per output and can trigger security scanner false positives (e.g., ClamAV signatures). **Always import the submodule as a namespace** (the submodule *is* the namespace — it has no self-named export): ```typescript // CORRECT — deep namespace import, tree-shakes unused import * as FetchHttpClient from '@effect/platform/FetchHttpClient'; // use: FetchHttpClient.layer // WRONG — barrel import drags in HttpApiSwagger import { FetchHttpClient } from '@effect/platform'; ``` Matches the repo's `import * as Effect from 'effect/Effect'` style. Applies to all `@effect/*` packages. Check import source before committing. ## Observability ```typescript // 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 }) ) }); ``` See `references/observability-patterns.md` for metrics and tracing patterns. ## Reference Files For detailed patterns, consult these reference files in the `references/` directory: - `composition-style.md` - `.pipe`; `const` only if read ≥2×; terminal runner; point-free safety; tap for side effects; Match dispatch; guard clauses; linear body as point-free pipe vs generator; recovery on a subsequence (pipe from the first Effect — not a nested `Effect.gen`) - `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 - `anti-patterns.md` - Complete list of forbidden patterns - `diagnostics-findings.md` - Effect LS finding → fix, per rule - `observability-patterns.md` - Logging, metrics, config patterns