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>
# or whole project:
npx effect-language-service diagnostics --project tsconfig.json
- The PostToolUse
verify-on-edit.shhook auto-runs--file <edited>on every.tsEdit/Write and surfaces output asfollowup_message. Address what it reports. - Address warnings AND messages, not just errors.
references/diagnostics-findings.mdmaps each common finding to its fix;config/effect-diagnostics.jsonenforcedRulesis the build gate. - Enforcing a rule takes two edits, not just an
enforcedRulesentry — seereferences/diagnostics-findings.md. - After a batch of edits, run
--project tsconfig.jsonfor the affected package to catch cross-file issues. effect-language-service quickfixesshows 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 |
Numeric milliseconds as number params or TIMEOUT_MS = 30_000 constants |
| 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 |
| 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<Record> 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.
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 = 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); seereferences/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
// WRONG - passing shared infra as params
const createStatusBar = (pubsub: PubSub.PubSub<void>, stateRef: SubscriptionRef.SubscriptionRef<State>) =>
Effect.gen(...)
// Caller must create and pass; wiring scattered at call sites
// CORRECT - yield inside, build in layer
const PubSubTag = Context.GenericTag<PubSub.PubSub<void>>("PubSub")
const createStatusBar = Effect.gen(function* () {
const pubsub = yield* PubSubTag
const stateRef = yield* StateRefTag
// ...
})
// Layer: Layer.effect(PubSubTag, PubSub.sliding<void>(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.
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 }))
})
);
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.
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 TenantId = Schema.UUID.pipe(Schema.brand('@App/TenantId'));
export type TenantId = Schema.Schema.Type<typeof TenantId>;
// 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<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)),
tenantId: TenantId
});
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 required; enforced by local/require-effect-fn-span-name.
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).
// 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:
// 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.
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.
export class StatusService extends Effect.Service<StatusService>()('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:
// 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.
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 <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Effectful atom → Result; handle loading/error/success
function UserProfile() {
return Result.builder(useAtomValue(userAtom))
.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();
}
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.
// 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<Record<K, V[]>>, so every consumer filters or defaults the undefined. Arr.groupBy returns Record<K, NonEmptyArray<V>> — total, so Object.entries needs no guard.
import * as Arr from 'effect/Array';
const grouped = Arr.groupBy(ids, id => id.slice(0, 3)); // Record<string, NonEmptyArray<string>>
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 |
import * as Schema from 'effect/Schema';
import { isNotNull, isNotUndefined, isNullable, isUndefined } from 'effect/Predicate';
// T | undefined — the common case (Optional<T>, 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<OrgId>`; 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.
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).
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):
// 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
// 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;constonly 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 nestedEffect.gen)service-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 layersanti-patterns.md- Complete list of forbidden patternsdiagnostics-findings.md- Effect LS finding → fix, per ruleobservability-patterns.md- Logging, metrics, config patterns