
Effect Best Practices
- 37 installs
- 1k repo stars
- Updated August 4, 2026
- forcedotcom/salesforcedx-vscode
Enforces consistent Effect-TS patterns for services, tagged errors, layer composition, and effect-atom React components.
About
Provides opinionated rules and a diagnostics workflow for writing Effect-TS code, covering Effect.Service, Schema.TaggedError, Layer composition, and effect-atom. A developer uses it while writing or reviewing Effect-TS code to keep patterns consistent.
- Quick-reference DO/DON'T table for services, errors, and layers
- Uses effect-language-service CLI diagnostics via a PostToolUse hook
Effect Best Practices by the numbers
- 37 all-time installs (skills.sh)
- Ranked #3,308 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/forcedotcom/salesforcedx-vscode --skill effect-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 37 |
|---|---|
| repo stars | ★ 1k |
| Last updated | August 4, 2026 |
| Repository | forcedotcom/salesforcedx-vscode ↗ |
What it does
Enforces consistent Effect-TS patterns for services, tagged errors, layer composition, and effect-atom React components.
Files
Effect-TS Best Practices
This skill enforces opinionated, consistent patterns for Effect-TS codebases.
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. Common findings:
effectFnOpportunity(gen→fn),unnecessaryFailYieldableError(yield error directly),effectSucceedWithVoid(Effect.succeed(undefined)→Effect.void),globalErrorInEffectCatch/Failure(use tagged error, notnew Error). - 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 | 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 };
})
}) {}
// 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
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
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.
// 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: 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);
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);
// ...
});
// 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
});
// 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)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
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).
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 - swallowing errors (most errors surface to user; only catch when ignoring intentionally or providing better message)
yield * effect.pipe(Effect.catchAll(() => Effect.void));
// FORBIDDEN - console.log
console.log('debug'); // Use Effect.log
// FORBIDDEN - process.env directly (runtime config)
const key = process.env.API_KEY; // Use Config.string("API_KEY")
// EXCEPTION - build-time/bundle-time variables (e.g., ESBUILD_*)
const platform = process.env.ESBUILD_PLATFORM === 'web' ? webImpl : desktopImpl; // OK - build-time conditional
// 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.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:
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 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
Anti-Patterns (Forbidden)
These patterns are never acceptable in Effect-TS code. Each is listed with rationale and the correct alternative.
FORBIDDEN: Effect.runSync/runPromise Inside Services
// FORBIDDEN
export class UserService extends Effect.Service<UserService>()("UserService", {
effect: Effect.gen(function* () {
const findById = (id: UserId) => {
// Running effects synchronously breaks composition
const user = Effect.runSync(repo.findById(id))
return user
}
return { findById }
}),
}) {}Why: Breaks Effect's composition model, loses error handling, can't be tested, loses tracing.
Correct:
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
return yield* repo.findById(id)
})FORBIDDEN: throw Inside Effect.gen
// FORBIDDEN
yield* Effect.gen(function* () {
const user = yield* repo.findById(id)
if (!user) {
throw new Error("User not found") // Bypasses Effect error channel
}
return user
})Why: Throws bypass Effect's error channel, can't be caught with catchTag, breaks type safety.
Correct:
yield* Effect.gen(function* () {
const user = yield* repo.findById(id)
if (!user) {
return yield* Effect.fail(new UserNotFoundError({ userId: id, message: "Not found" }))
}
return user
})FORBIDDEN: catchAll Losing Type Information
// FORBIDDEN
yield* someEffect.pipe(
Effect.catchAll((err) =>
Effect.fail(new GenericError({ message: "Something failed" }))
)
)Why: Loses specific error information, makes debugging harder, prevents specific error handling downstream.
Correct:
yield* someEffect.pipe(
Effect.catchTags({
DatabaseError: (err) => Effect.fail(new ServiceUnavailableError({ message: err.message })),
ValidationError: (err) => Effect.fail(new BadRequestError({ message: err.message })),
}),
)FORBIDDEN: any/unknown Casts
// FORBIDDEN
const data = someValue as any
const result = (await fetch(url)) as unknown as MyTypeWhy: Completely bypasses type safety, can cause runtime errors, loses Effect's type guarantees.
Correct:
// Use Schema for parsing unknown data
const result = yield* Schema.decodeUnknown(MyType)(someValue)
// Or explicit type guards
if (isMyType(someValue)) {
// Now safely typed
}FORBIDDEN: Promise in Service Signatures
// FORBIDDEN
export class UserService extends Effect.Service<UserService>()("UserService", {
effect: Effect.gen(function* () {
return {
findById: async (id: UserId): Promise<User> => {
// Using Promise instead of Effect
}
}
}),
}) {}Why: Loses Effect's error handling, can't compose with other Effects, loses tracing/metrics.
Correct:
const findById = Effect.fn("UserService.findById")(
function* (id: UserId): Effect.Effect<User, UserNotFoundError> {
// ...
}
)FORBIDDEN: console.log
// FORBIDDEN
console.log("Processing order:", orderId)
console.error("Error:", error)Why: Not structured, not captured by Effect's logging system, lost in production telemetry.
Correct:
yield* Effect.log("Processing order", { orderId })
yield* Effect.logError("Operation failed", { error: String(error) })FORBIDDEN: process.env Directly
// FORBIDDEN
const apiKey = process.env.API_KEY
const port = parseInt(process.env.PORT || "3000")Why: No validation, no type safety, fails silently if missing, hard to test.
Correct:
const config = yield* Config.all({
apiKey: Config.secret("API_KEY"),
port: Config.integer("PORT").pipe(Config.withDefault(3000)),
})FORBIDDEN: null/undefined in Domain Types
// FORBIDDEN
type User = {
name: string
bio: string | null
avatar: string | undefined
}Why: Null/undefined handling is error-prone, loses the explicit "absence" semantics.
Correct:
const User = Schema.Struct({
name: Schema.String,
bio: Schema.Option(Schema.String),
avatar: Schema.Option(Schema.String),
})FORBIDDEN: Option.getOrThrow
// FORBIDDEN
const user = Option.getOrThrow(maybeUser)
const name = pipe(maybeName, Option.getOrThrow)Why: Throws exceptions, bypasses Effect's error handling, fails at runtime instead of compile time.
Correct:
// Handle both cases explicitly
yield* Option.match(maybeUser, {
onNone: () => Effect.fail(new UserNotFoundError({ userId, message: "Not found" })),
onSome: Effect.succeed,
})
// Or provide a default
const name = Option.getOrElse(maybeName, () => "Anonymous")
// Or use Option.map for transformations
const upperName = Option.map(maybeName, (n) => n.toUpperCase())FORBIDDEN: Context.Tag for Business Services
// FORBIDDEN
export class UserService extends Context.Tag("UserService")<
UserService,
{ findById: (id: UserId) => Effect.Effect<User, UserNotFoundError> }
>() {
static Default = Layer.effect(this, Effect.gen(function* () { ... }))
}Why: Requires manual layer creation, no built-in accessors, more boilerplate.
Correct:
export class UserService extends Effect.Service<UserService>()("UserService", {
accessors: true,
dependencies: [...],
effect: Effect.gen(function* () { ... }),
}) {}FORBIDDEN: Ignoring Errors with orDie
// FORBIDDEN (in most cases)
yield* someEffect.pipe(Effect.orDie)Why: Converts recoverable errors to defects (unrecoverable), loses error information.
Acceptable exceptions:
- Truly unrecoverable situations (invalid program state)
- After exhausting all recovery options
- In test setup code
Correct:
// Handle errors explicitly
yield* someEffect.pipe(
Effect.catchTag("RecoverableError", (err) =>
Effect.fail(new DomainError({ message: err.message }))
),
)FORBIDDEN: mapError Instead of catchTag
// FORBIDDEN
yield* effect.pipe(
Effect.mapError((err) => new GenericError({ message: String(err) }))
)Why: Loses error type information, can't discriminate between error types.
Correct:
yield* effect.pipe(
Effect.catchTag("SpecificError", (err) =>
Effect.fail(new MappedError({ message: err.message }))
),
)FORBIDDEN: Mixing Effect and Promise Chains
// FORBIDDEN
const result = await someEffect.pipe(
Effect.runPromise,
).then(data => {
// Mixing Promise chain with Effect
return Effect.runPromise(anotherEffect(data))
})Why: Loses Effect composition benefits, error handling becomes inconsistent.
Correct:
const program = Effect.gen(function* () {
const data = yield* someEffect
return yield* anotherEffect(data)
})
const result = await Effect.runPromise(program)FORBIDDEN: Mutable State Without Ref
// FORBIDDEN
let counter = 0
const increment = Effect.sync(() => { counter++ })Why: Race conditions, not testable, not composable, breaks referential transparency.
Correct:
const program = Effect.gen(function* () {
const counter = yield* Ref.make(0)
yield* Ref.update(counter, (n) => n + 1)
return yield* Ref.get(counter)
})FORBIDDEN: Using Date.now() or new Date() Directly
// FORBIDDEN
const now = new Date()
const timestamp = Date.now()Why: Not testable, introduces non-determinism, hard to mock in tests.
Correct:
import { Clock } from "effect"
const now = yield* Clock.currentTimeMillis
const date = yield* Clock.currentTimeZone.pipe(
Effect.map((tz) => new Date())
)Error Patterns
Why Explicit Error Types?
Generic errors like BadRequestError or NotFoundError seem convenient but create problems:
| Generic Error | Problems |
|---|---|
NotFoundError | Which resource? How should frontend recover? |
BadRequestError | What's invalid? Can user fix it? |
UnauthorizedError | Session expired? Wrong credentials? Missing permission? |
InternalServerError | Retryable? User action needed? |
Explicit errors enable: 1. Specific UI messages - "Your session expired" vs generic "Unauthorized" 2. Targeted recovery - Refresh token vs show login page 3. Better observability - Group errors by specific type in dashboards 4. Type-safe handling - catchTag("SessionExpiredError") vs generic catch
Anti-Pattern: Generic Error Mapping
// ❌ WRONG - Collapsing to generic HTTP errors
export class NotFoundError extends Schema.TaggedError<NotFoundError>()(
"NotFoundError",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 404 }),
) {}
// At API boundaries:
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 receives: { _tag: "NotFoundError", message: "Not found" }
// - Can't show specific message ("User doesn't exist" vs "Channel was deleted")
// - Can't take specific action (redirect to user search vs channel list)
// - Debugging is harder (which resource was missing?)// ✅ CORRECT - Keep explicit errors all the way to frontend
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 }),
) {}
// Frontend can handle each case:
Result.builder(result)
.onErrorTag("UserNotFoundError", (err) => <UserNotFoundMessage userId={err.userId} />)
.onErrorTag("ChannelNotFoundError", (err) => <ChannelDeletedMessage />)
.onErrorTag("SessionExpiredError", () => <RedirectToLogin />)
.render()Error Naming Conventions
| Pattern | Example | Use For |
|---|---|---|
{Entity}NotFoundError | UserNotFoundError, ChannelNotFoundError | Resource lookups |
{Entity}{Action}Error | UserCreateError, MessageUpdateError | Mutations that fail |
{Feature}Error | SessionExpiredError, RateLimitExceededError | Feature-specific failures |
{Integration}Error | WorkOSUserFetchError, StripePaymentError | External service errors |
Invalid{Field}Error | InvalidEmailError, InvalidPasswordError | Validation failures |
Rich Error Context
Include context fields that help with debugging and UI handling:
// Entity errors → include entity ID
export class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
"UserNotFoundError",
{
userId: UserId, // Which user?
message: Schema.String,
},
HttpApiSchema.annotations({ status: 404 }),
) {}
// Action errors → include input that failed
export class UserCreateError extends Schema.TaggedError<UserCreateError>()(
"UserCreateError",
{
email: Schema.String, // What email failed?
reason: Schema.String, // Why? "duplicate", "invalid domain"
message: Schema.String,
},
HttpApiSchema.annotations({ status: 400 }),
) {}
// Integration errors → include service name and retryable flag
export class StripePaymentError extends Schema.TaggedError<StripePaymentError>()(
"StripePaymentError",
{
stripeErrorCode: Schema.String,
retryable: Schema.Boolean,
message: Schema.String,
},
HttpApiSchema.annotations({ status: 402 }),
) {}
// Auth errors → include expiry info
export class SessionExpiredError extends Schema.TaggedError<SessionExpiredError>()(
"SessionExpiredError",
{
sessionId: SessionId,
expiredAt: Schema.DateTimeUtc,
message: Schema.String,
},
HttpApiSchema.annotations({ status: 401 }),
) {}Schema.TaggedError for All Errors
Always use `Schema.TaggedError` for defining errors. This provides:
1. Serialization - Errors can be sent over RPC/network 2. Type safety - _tag discriminator enables catchTag 3. Consistent structure - All errors have predictable shape 4. HTTP status mapping - Via HttpApiSchema.annotations
Basic Error Definition
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 }),
) {}
export class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>()(
"UnauthorizedError",
{
message: Schema.String,
},
HttpApiSchema.annotations({ status: 401 }),
) {}
export class ForbiddenError extends Schema.TaggedError<ForbiddenError>()(
"ForbiddenError",
{
message: Schema.String,
requiredPermission: Schema.optional(Schema.String),
},
HttpApiSchema.annotations({ status: 403 }),
) {}Required Fields
Every error should have:
message: Schema.String- Human-readable description- Relevant context fields (IDs, etc.)
- Optional
cause: Schema.optional(Schema.String)for error chains
Error Handling with catchTag/catchTags
Never use `catchAll` or `mapError` when you can use catchTag/catchTags. These preserve type information and enable precise error handling.
catchTag for Single Error Types
const findUser = Effect.fn("UserService.findUser")(function* (id: UserId) {
return yield* repo.findById(id).pipe(
Effect.catchTag("DatabaseError", (err) =>
Effect.fail(new UserNotFoundError({
userId: id,
message: `Database lookup failed: ${err.message}`,
}))
),
)
})catchTags for Multiple Error Types
const processOrder = Effect.fn("OrderService.processOrder")(function* (input: OrderInput) {
return yield* validateAndProcess(input).pipe(
Effect.catchTags({
ValidationError: (err) =>
Effect.fail(new OrderValidationError({
message: err.message,
field: err.field,
})),
PaymentError: (err) =>
Effect.fail(new OrderPaymentError({
message: `Payment failed: ${err.message}`,
code: err.code,
})),
InventoryError: (err) =>
Effect.fail(new OrderInventoryError({
productId: err.productId,
message: "Insufficient inventory",
})),
}),
)
})Why Not catchAll?
// WRONG - Loses type information
yield* effect.pipe(
Effect.catchAll((err) =>
Effect.fail(new InternalServerError({ message: "Something failed" }))
)
)
// Problems:
// 1. Can't distinguish error types downstream
// 2. Hides useful error context
// 3. Makes debugging harder
// 4. Frontend can't show specific messagesError Remapping Pattern
Create reusable error remapping functions for common transformations:
import { Effect } from "effect"
export const withRemapDbErrors = <A, E, R>(
effect: Effect.Effect<A, E | DatabaseError | ConnectionError, R>,
context: { entityType: string; entityId: string }
): Effect.Effect<A, E | EntityNotFoundError | ServiceUnavailableError, R> =>
effect.pipe(
Effect.catchTag("DatabaseError", (err) =>
Effect.fail(new EntityNotFoundError({
entityType: context.entityType,
entityId: context.entityId,
message: `${context.entityType} not found`,
}))
),
Effect.catchTag("ConnectionError", (err) =>
Effect.fail(new ServiceUnavailableError({
message: "Database connection unavailable",
cause: err.message,
}))
),
)
// Usage
const findUser = Effect.fn("UserService.findUser")(function* (id: UserId) {
return yield* repo.findById(id).pipe(
withRemapDbErrors({ entityType: "User", entityId: id })
)
})Retryable Errors Pattern
For errors that may be transient, add a retryable property:
export class ServiceUnavailableError extends Schema.TaggedError<ServiceUnavailableError>()(
"ServiceUnavailableError",
{
message: Schema.String,
cause: Schema.optional(Schema.String),
retryable: Schema.optionalWith(Schema.Boolean, { default: () => true }),
},
HttpApiSchema.annotations({ status: 503 }),
) {}
export class RateLimitError extends Schema.TaggedError<RateLimitError>()(
"RateLimitError",
{
message: Schema.String,
retryAfter: Schema.optional(Schema.Number),
retryable: Schema.optionalWith(Schema.Boolean, { default: () => true }),
},
HttpApiSchema.annotations({ status: 429 }),
) {}
// Non-retryable error
export class ValidationError extends Schema.TaggedError<ValidationError>()(
"ValidationError",
{
message: Schema.String,
field: Schema.String,
retryable: Schema.optionalWith(Schema.Boolean, { default: () => false }),
},
HttpApiSchema.annotations({ status: 400 }),
) {}Retry Based on Error Property
import { Effect, Schedule } from "effect"
const withRetry = <A, E extends { retryable?: boolean }, R>(
effect: Effect.Effect<A, E, R>
): Effect.Effect<A, E, R> =>
effect.pipe(
Effect.retry(
Schedule.exponential("100 millis").pipe(
Schedule.intersect(Schedule.recurs(3)),
Schedule.whileInput((err: E) => err.retryable === true),
)
),
)
// Usage
yield* callExternalApi(request).pipe(withRetry)Error Unions for Activities
When defining workflow activities, use explicit error unions:
// Activity error type - union of possible errors
export type GetChannelMembersError =
| DatabaseError
| ChannelNotFoundError
export class DatabaseError extends Schema.TaggedError<DatabaseError>()(
"DatabaseError",
{
message: Schema.String,
cause: Schema.optional(Schema.String),
retryable: Schema.optionalWith(Schema.Boolean, { default: () => true }),
},
) {}
export class ChannelNotFoundError extends Schema.TaggedError<ChannelNotFoundError>()(
"ChannelNotFoundError",
{
channelId: ChannelId,
message: Schema.String,
retryable: Schema.optionalWith(Schema.Boolean, { default: () => false }),
},
) {}
// In activity definition
yield* Activity.make({
name: "GetChannelMembers",
success: ChannelMembersResult,
error: Schema.Union(DatabaseError, ChannelNotFoundError),
execute: Effect.gen(function* () {
// ...
}),
})HTTP Status Codes (Without Generic Errors)
Map HTTP status codes at the error level, not by creating generic error classes. Each explicit error can have its own HTTP status.
// ✅ CORRECT - Domain errors with HTTP status annotations
export class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
"UserNotFoundError",
{ userId: UserId, message: Schema.String },
HttpApiSchema.annotations({ status: 404 }), // Status on specific error
) {}
export class ChannelNotFoundError extends Schema.TaggedError<ChannelNotFoundError>()(
"ChannelNotFoundError",
{ channelId: ChannelId, message: Schema.String },
HttpApiSchema.annotations({ status: 404 }), // Same status, different error
) {}
export class SessionExpiredError extends Schema.TaggedError<SessionExpiredError>()(
"SessionExpiredError",
{ sessionId: SessionId, expiredAt: Schema.DateTimeUtc, message: Schema.String },
HttpApiSchema.annotations({ status: 401 }),
) {}
export class InvalidCredentialsError extends Schema.TaggedError<InvalidCredentialsError>()(
"InvalidCredentialsError",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 401 }), // Same status, different meaning
) {}// ❌ WRONG - Generic HTTP error classes
export class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 401 }),
) {}
// Then mapping everything to it - loses critical information!
Effect.catchTags({
SessionExpiredError: (err) => Effect.fail(new UnauthorizedError({ message: "Unauthorized" })),
InvalidCredentialsError: (err) => Effect.fail(new UnauthorizedError({ message: "Unauthorized" })),
MissingTokenError: (err) => Effect.fail(new UnauthorizedError({ message: "Unauthorized" })),
})
// Frontend can't distinguish: expired session vs wrong password vs missing tokenWhen Generic Errors Are Acceptable
Generic errors are only acceptable for truly unrecoverable internal errors where:
- The frontend can only show "Something went wrong"
- No user action can fix it
- You're hiding internal details for security
// Acceptable for unrecoverable errors
export class InternalServerError extends Schema.TaggedError<InternalServerError>()(
"InternalServerError",
{ message: Schema.String, requestId: Schema.optional(Schema.String) },
HttpApiSchema.annotations({ status: 500 }),
) {}
// Use sparingly - only for truly unexpected errors
Effect.catchAll((unexpectedError) =>
Effect.fail(new InternalServerError({
message: "An unexpected error occurred",
requestId: context.requestId,
}))
)Error Logging
Log errors with structured context:
const processWithLogging = Effect.fn("OrderService.process")(function* (orderId: OrderId) {
return yield* processOrder(orderId).pipe(
Effect.tapError((err) =>
Effect.log("Order processing failed", {
orderId,
errorTag: err._tag,
errorMessage: err.message,
})
),
)
})Layer Patterns
Dependencies in Effect.Service
Critical rule: Always declare dependencies in the dependencies array of Effect.Service. This ensures proper composition and avoids "leaked dependencies" that require manual wiring at usage sites.
Correct Pattern
export class OrderService extends Effect.Service<OrderService>()("OrderService", {
accessors: true,
dependencies: [
UserService.Default,
ProductService.Default,
InventoryService.Default,
PaymentService.Default,
],
effect: Effect.gen(function* () {
const users = yield* UserService
const products = yield* ProductService
const inventory = yield* InventoryService
const payments = yield* PaymentService
// Service implementation...
return { /* methods */ }
}),
}) {}
// At app root - simple, flat composition
const AppLive = Layer.mergeAll(
OrderService.Default,
// Other top-level services
NotificationService.Default,
AnalyticsService.Default,
)Wrong Pattern (Leaked Dependencies)
// WRONG - Dependencies not declared
export class OrderService extends Effect.Service<OrderService>()("OrderService", {
accessors: true,
effect: Effect.gen(function* () {
const users = yield* UserService // Not in dependencies!
// ...
}),
}) {}
// Now every usage requires manual wiring
const program = OrderService.create(input).pipe(
Effect.provide(
OrderService.Default.pipe(
Layer.provide(UserService.Default),
Layer.provide(ProductService.Default),
// Easy to forget one, causes runtime errors
)
),
)Infrastructure Layers
Infrastructure layers (Database, Redis, HTTP clients) are acceptable to leave as "leaked" dependencies because:
1. They're provided once at the application root 2. They don't change between test/production (different implementations, same interface) 3. They're true infrastructure, not business logic
// Infrastructure can be provided at app root
import { PgClient } from "@effect/sql-pg"
const DatabaseLive = PgClient.layer({
host: Config.string("DB_HOST"),
port: Config.integer("DB_PORT"),
database: Config.string("DB_NAME"),
username: Config.string("DB_USER"),
password: Config.secret("DB_PASSWORD"),
})
// Services use database but don't declare it in dependencies
export class UserRepo extends Effect.Service<UserRepo>()("UserRepo", {
accessors: true,
// No dependencies array - PgClient provided at app root
effect: Effect.gen(function* () {
const sql = yield* PgClient.PgClient
const findById = Effect.fn("UserRepo.findById")(function* (id: UserId) {
const rows = yield* sql`SELECT * FROM users WHERE id = ${id}`.pipe(Effect.orDie)
return rows[0] as User | undefined
})
return { findById }
}),
}) {}
// App root provides infrastructure once
const AppLive = Layer.mergeAll(
OrderService.Default,
UserService.Default,
).pipe(
Layer.provide(DatabaseLive), // Infrastructure provided here
Layer.provide(RedisLive),
)Layer.mergeAll Over Nested Provides
Use `Layer.mergeAll` for composing layers at the same level:
// CORRECT - Flat composition
const ServicesLive = Layer.mergeAll(
UserService.Default,
OrderService.Default,
ProductService.Default,
NotificationService.Default,
)
const InfrastructureLive = Layer.mergeAll(
DatabaseLive,
RedisLive,
HttpClientLive,
)
const AppLive = ServicesLive.pipe(
Layer.provide(InfrastructureLive),
)// WRONG - Deeply nested, hard to read
const AppLive = UserService.Default.pipe(
Layer.provide(
OrderService.Default.pipe(
Layer.provide(
ProductService.Default.pipe(
Layer.provide(DatabaseLive),
),
),
),
),
)Layer Naming Conventions
Use suffixes to indicate layer type:
ServiceLive- Production implementationServiceTest- Test/mock implementationServiceLayer- Generic layer (rare)
// Production
export const UserServiceLive = UserService.Default
// Test with mocks
export const UserServiceTest = Layer.succeed(
UserService,
UserService.of({
findById: (id) => Effect.succeed(mockUser),
create: (input) => Effect.succeed({ id: UserId.make("test-id"), ...input }),
})
)
// Test with in-memory state
export class UserServiceInMemory extends Effect.Service<UserService>()("UserService", {
accessors: true,
effect: Effect.gen(function* () {
const store = new Map<string, User>()
return {
findById: Effect.fn("UserService.findById")(function* (id) {
const user = store.get(id)
if (!user) return yield* Effect.fail(new UserNotFoundError({ userId: id }))
return user
}),
create: Effect.fn("UserService.create")(function* (input) {
const user = { id: UserId.make(crypto.randomUUID()), ...input }
store.set(user.id, user)
return user
}),
}
}),
}) {}Layer.unwrapEffect for Config-Dependent Layers
When a layer needs async configuration:
import { Config, Effect, Layer } from "effect"
// Layer that depends on config
const ApiClientLive = Layer.unwrapEffect(
Effect.gen(function* () {
const apiKey = yield* Config.string("API_KEY")
const baseUrl = yield* Config.string("API_BASE_URL")
const timeout = yield* Config.integer("API_TIMEOUT").pipe(
Config.withDefault(5000)
)
return Layer.succeed(
ApiClient,
new ApiClientImpl({ apiKey, baseUrl, timeout })
)
})
)
// Layer that validates config
const ValidatedConfigLive = Layer.unwrapEffect(
Effect.gen(function* () {
const config = yield* Config.all({
dbUrl: Config.string("DATABASE_URL"),
redisUrl: Config.string("REDIS_URL"),
port: Config.integer("PORT"),
})
// Validate config
if (!config.dbUrl.startsWith("postgresql://")) {
return yield* Effect.fail(new ConfigError({ message: "Invalid DATABASE_URL" }))
}
return Layer.succeed(AppConfig, config)
})
)Scoped Layers
For resources that need cleanup:
import { Effect, Layer, Scope } from "effect"
// Resource that needs cleanup
const DatabaseConnectionLive = Layer.scoped(
DatabaseConnection,
Effect.acquireRelease(
Effect.gen(function* () {
const pool = yield* createPool(config)
yield* Effect.log("Database pool created")
return pool
}),
(pool) =>
Effect.gen(function* () {
yield* pool.end()
yield* Effect.log("Database pool closed")
}).pipe(Effect.orDie)
)
)
// Service using scoped resource
export class UserRepo extends Effect.Service<UserRepo>()("UserRepo", {
accessors: true,
effect: Effect.gen(function* () {
const db = yield* DatabaseConnection
return {
findById: Effect.fn("UserRepo.findById")(function* (id) {
return yield* db.query("SELECT * FROM users WHERE id = $1", [id])
}),
}
}),
}) {}Testing Layer Composition
// test/setup.ts
import { Layer } from "effect"
export const TestLive = Layer.mergeAll(
UserServiceTest,
OrderServiceTest,
ProductServiceTest,
).pipe(
Layer.provide(InMemoryDatabaseLive),
)
// test/user.test.ts
import { Effect } from "effect"
import { TestLive } from "./setup"
describe("UserService", () => {
it("creates users", async () => {
const program = Effect.gen(function* () {
const user = yield* UserService.create({
email: "test@example.com",
name: "Test User",
})
expect(user.email).toBe("test@example.com")
})
await Effect.runPromise(program.pipe(Effect.provide(TestLive)))
})
})Layer.effect vs Layer.succeed
// Layer.succeed - for static values (no effects)
const ConfigLive = Layer.succeed(AppConfig, {
port: 3000,
env: "development",
})
// Layer.effect - when construction needs effects
const LoggerLive = Layer.effect(
Logger,
Effect.gen(function* () {
const config = yield* AppConfig
const transport = config.env === "production"
? createCloudTransport()
: createConsoleTransport()
return new LoggerImpl(transport)
})
)Lazy Layers
For expensive initialization that should be deferred:
const ExpensiveServiceLive = Layer.lazy(() => {
// This code runs only when the layer is first used
return Layer.effect(
ExpensiveService,
Effect.gen(function* () {
yield* Effect.log("Initializing expensive service...")
const client = yield* createExpensiveClient()
return new ExpensiveServiceImpl(client)
})
)
})Observability Patterns
Structured Logging with Effect.log
Always use Effect.log instead of console.log. Effect.log provides:
- Structured data
- Log levels
- Integration with telemetry systems
- Testability
Basic Logging
// Simple message
yield* Effect.log("Processing started")
// With structured data
yield* Effect.log("Processing order", {
orderId,
userId,
amount,
currency,
})
// Different log levels
yield* Effect.logDebug("Cache lookup", { key, hit: true })
yield* Effect.logInfo("User logged in", { userId })
yield* Effect.logWarning("Rate limit approaching", { current: 95, limit: 100 })
yield* Effect.logError("Payment failed", { orderId, reason: error.message })
yield* Effect.logFatal("Database connection lost")Logging in Services
const processOrder = Effect.fn("OrderService.processOrder")(function* (input: OrderInput) {
yield* Effect.log("Starting order processing", { orderId: input.orderId })
const result = yield* validateAndProcess(input).pipe(
Effect.tap(() => Effect.log("Order processed successfully")),
Effect.tapError((err) =>
Effect.logError("Order processing failed", {
orderId: input.orderId,
error: err._tag,
message: err.message,
})
),
)
return result
})Effect.fn for Automatic Tracing
Always use Effect.fn for service methods. This automatically creates spans with proper names:
// Creates span: "UserService.findById"
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
// Automatic span creation with:
// - Start/end timing
// - Error capture
// - Parameter tracking (if annotated)
})
// Creates span: "PaymentService.processPayment"
const processPayment = Effect.fn("PaymentService.processPayment")(
function* (orderId: OrderId, amount: number) {
// ...
}
)Naming Convention
Use ServiceName.methodName format consistently:
UserService.findByIdOrderService.createPaymentService.refundNotificationService.sendEmail
Span Annotations
Add important context to spans, but don't overdo it:
const processOrder = Effect.fn("OrderService.process")(function* (orderId: OrderId) {
// GOOD - Important business identifiers
yield* Effect.annotateCurrentSpan("orderId", orderId)
yield* Effect.annotateCurrentSpan("userId", order.userId)
yield* Effect.annotateCurrentSpan("totalAmount", order.total)
// BAD - Too much detail, creates noise
// yield* Effect.annotateCurrentSpan("step", "validating")
// yield* Effect.annotateCurrentSpan("itemCount", order.items.length)
// yield* Effect.annotateCurrentSpan("item0Name", order.items[0].name)
})What to Annotate
Do annotate:
- Entity IDs (orderId, userId, etc.)
- Important business values (amounts, statuses)
- Error context when failing
Don't annotate:
- Step-by-step progress
- Individual item details
- Internal implementation state
- Sensitive data (PII, secrets)
Current vs Root Span
Effect.annotateCurrentSpan writes to the current fiber's span. In this codebase, only top-level (and command) spans are exported to App Insights / O11y — annotations on inner spans are visible only in local debug exporters. When an attribute needs to reach production telemetry from deep in the call tree, use annotateRootSpan from @salesforce/effect-ext-utils:
import { annotateRootSpan } from "@salesforce/effect-ext-utils"
const processOrder = Effect.fn("OrderService.process")(function* (orderId: OrderId) {
// Local debug only — stays on the current span
yield* Effect.annotateCurrentSpan("step", "validating")
// Reaches App Insights / O11y — promoted to the trace root
yield* annotateRootSpan({ orderId, userId: order.userId })
})annotateRootSpan mirrors Effect.annotateCurrentSpan's API (both (key, value) and record overloads) and no-ops with a debug log when there is no current span.
Metrics
Counter
import { Metric } from "effect"
// Define metrics at module level
const ordersProcessed = Metric.counter("orders_processed", {
description: "Total orders processed",
})
const ordersFailed = Metric.counter("orders_failed", {
description: "Total orders that failed processing",
})
// Use in service
const processOrder = Effect.fn("OrderService.process")(function* (input: OrderInput) {
return yield* process(input).pipe(
Effect.tap(() => Metric.increment(ordersProcessed)),
Effect.tapError(() => Metric.increment(ordersFailed)),
)
})Counter with Tags
const httpRequests = Metric.counter("http_requests_total", {
description: "Total HTTP requests",
})
// Tag with method and status
yield* Metric.increment(httpRequests).pipe(
Metric.tagged("method", request.method),
Metric.tagged("status", String(response.status)),
Metric.tagged("path", request.path),
)Gauge
const activeConnections = Metric.gauge("active_connections", {
description: "Number of active connections",
})
// Update gauge
yield* Metric.set(activeConnections, connectionCount)
// Or increment/decrement
yield* Metric.increment(activeConnections)
yield* Metric.decrement(activeConnections)Histogram
const requestDuration = Metric.histogram("request_duration_ms", {
description: "Request duration in milliseconds",
boundaries: [10, 50, 100, 250, 500, 1000, 2500, 5000],
})
// Record value
yield* Metric.update(requestDuration, durationMs)
// Or use timer helper
const timedEffect = effect.pipe(
Metric.timerWithHistogram(requestDuration),
)Configuration with Config
Always use Config instead of process.env:
Basic Config
import { Config, Effect } from "effect"
const config = Config.all({
port: Config.integer("PORT").pipe(Config.withDefault(3000)),
host: Config.string("HOST").pipe(Config.withDefault("localhost")),
env: Config.literal("development", "staging", "production")("NODE_ENV"),
})
// Use in layer
const ServerLive = Layer.unwrapEffect(
Effect.gen(function* () {
const { port, host, env } = yield* config
return Layer.succeed(ServerConfig, { port, host, env })
})
)Config with Validation
const dbConfig = Config.all({
host: Config.string("DB_HOST"),
port: Config.integer("DB_PORT").pipe(
Config.validate({
message: "Port must be between 1 and 65535",
validation: (p) => p >= 1 && p <= 65535,
})
),
database: Config.string("DB_NAME"),
maxConnections: Config.integer("DB_MAX_CONNECTIONS").pipe(
Config.withDefault(10),
Config.validate({
message: "Max connections must be positive",
validation: (n) => n > 0,
})
),
})Secret Config
// For sensitive values that shouldn't be logged
const secretConfig = Config.all({
apiKey: Config.secret("API_KEY"), // Returns Secret<string>
dbPassword: Config.secret("DB_PASSWORD"),
})
// Using secrets
const program = Effect.gen(function* () {
const { apiKey, dbPassword } = yield* secretConfig
// Secret values are wrapped - use Secret.value to unwrap
const key = Secret.value(apiKey)
// Logging a Secret shows "[REDACTED]"
yield* Effect.log("Config loaded", { apiKey }) // Safe - shows [REDACTED]
})Config with Nested Structure
const appConfig = Config.all({
server: Config.all({
port: Config.integer("SERVER_PORT"),
host: Config.string("SERVER_HOST"),
}),
database: Config.all({
url: Config.string("DATABASE_URL"),
pool: Config.integer("DATABASE_POOL_SIZE").pipe(Config.withDefault(10)),
}),
features: Config.all({
enableBeta: Config.boolean("ENABLE_BETA").pipe(Config.withDefault(false)),
maxUploadSize: Config.integer("MAX_UPLOAD_SIZE").pipe(Config.withDefault(10485760)),
}),
})Log Level Configuration
import { Logger, LogLevel } from "effect"
// Set log level via config
const LoggerLive = Layer.unwrapEffect(
Effect.gen(function* () {
const level = yield* Config.literal(
"debug", "info", "warning", "error"
)("LOG_LEVEL").pipe(Config.withDefault("info"))
const logLevel = {
debug: LogLevel.Debug,
info: LogLevel.Info,
warning: LogLevel.Warning,
error: LogLevel.Error,
}[level]
return Logger.minimumLogLevel(logLevel)
})
)
// Production: structured JSON logging
const JsonLoggerLive = Logger.jsonCombining Observability
const processOrder = Effect.fn("OrderService.process")(function* (input: OrderInput) {
const startTime = yield* Effect.clockWith((clock) => clock.currentTimeMillis)
// Annotate span
yield* Effect.annotateCurrentSpan("orderId", input.orderId)
yield* Effect.annotateCurrentSpan("userId", input.userId)
// Log start
yield* Effect.log("Processing order", { orderId: input.orderId })
const result = yield* process(input).pipe(
Effect.tap((order) =>
Effect.gen(function* () {
const endTime = yield* Effect.clockWith((c) => c.currentTimeMillis)
const duration = endTime - startTime
// Record metric
yield* Metric.update(orderProcessingDuration, duration)
yield* Metric.increment(ordersProcessed)
// Log completion
yield* Effect.log("Order processed", {
orderId: input.orderId,
durationMs: duration,
})
})
),
Effect.tapError((err) =>
Effect.gen(function* () {
yield* Metric.increment(ordersFailed)
yield* Effect.logError("Order processing failed", {
orderId: input.orderId,
error: err._tag,
})
})
),
)
return result
})RPC & Cluster Patterns
RpcGroup for API Organization
Use `RpcGroup.make` to organize related RPC endpoints:
import { Rpc, RpcGroup } from "@effect/rpc"
import { Schema } from "effect"
// Group related operations
export const UserRpc = RpcGroup.make("User", {
// Queries (read operations)
findById: Rpc.query({
input: UserId,
output: User,
error: UserNotFoundError,
}),
list: Rpc.query({
input: Schema.Struct({
organizationId: OrganizationId,
limit: Schema.optionalWith(Schema.Number, { default: () => 50 }),
offset: Schema.optionalWith(Schema.Number, { default: () => 0 }),
}),
output: Schema.Array(User),
error: Schema.Never,
}),
// Mutations (write operations)
create: Rpc.mutation({
input: CreateUserInput,
output: User,
error: Schema.Union(UserCreateError, ValidationError),
}),
update: Rpc.mutation({
input: Schema.Struct({
id: UserId,
data: UpdateUserInput,
}),
output: User,
error: Schema.Union(UserNotFoundError, ValidationError),
}),
delete: Rpc.mutation({
input: UserId,
output: Schema.Void,
error: UserNotFoundError,
}),
})Query vs Mutation
- Rpc.query - Read operations, idempotent, cacheable
- Rpc.mutation - Write operations, may have side effects
// Query - safe to retry, can be cached
findById: Rpc.query({ ... }),
search: Rpc.query({ ... }),
list: Rpc.query({ ... }),
// Mutation - may modify state
create: Rpc.mutation({ ... }),
update: Rpc.mutation({ ... }),
delete: Rpc.mutation({ ... }),Error Unions in RPC
Always use explicit error unions for RPC error types:
// Explicit union of possible errors
create: Rpc.mutation({
input: CreateOrderInput,
output: Order,
error: Schema.Union(
ValidationError,
InsufficientInventoryError,
PaymentFailedError,
UserNotFoundError,
),
}),
// NOT - generic error type
create: Rpc.mutation({
input: CreateOrderInput,
output: Order,
error: GenericError, // WRONG - loses type information
}),RPC Middleware for Authentication
import { RpcMiddleware, Rpc } from "@effect/rpc"
import { Effect, Layer } from "effect"
// Context type for authenticated user
export class CurrentUser extends Context.Tag("CurrentUser")<
CurrentUser,
{ id: UserId; role: UserRole; organizationId: OrganizationId }
>() {}
// Auth middleware - extracts and validates auth
export class AuthMiddleware extends RpcMiddleware.Tag<AuthMiddleware>()(
"AuthMiddleware",
{
provides: CurrentUser,
failure: UnauthorizedError,
}
) {}
// Middleware implementation
export const AuthMiddlewareLive = Layer.effect(
AuthMiddleware,
Effect.gen(function* () {
const authService = yield* AuthService
return AuthMiddleware.of({
execute: (request) =>
Effect.gen(function* () {
const token = request.headers.get("authorization")?.replace("Bearer ", "")
if (!token) {
return yield* Effect.fail(new UnauthorizedError({ message: "Missing token" }))
}
const user = yield* authService.validateToken(token).pipe(
Effect.catchTag("TokenExpiredError", () =>
Effect.fail(new UnauthorizedError({ message: "Token expired" }))
),
Effect.catchTag("TokenInvalidError", () =>
Effect.fail(new UnauthorizedError({ message: "Invalid token" }))
),
)
return user
}),
})
})
)
// Protected RPC using middleware
export const ProtectedUserRpc = UserRpc.middleware(AuthMiddleware)Workflow Definition
Use `Workflow.make` with explicit idempotency keys:
import { Workflow } from "@effect/cluster"
import { Schema } from "effect"
export const OrderFulfillmentWorkflow = Workflow.make({
name: "OrderFulfillmentWorkflow",
payload: {
id: OrderId, // Execution ID
orderId: OrderId,
userId: UserId,
items: Schema.Array(OrderItem),
shippingAddress: ShippingAddress,
},
// Idempotency key prevents duplicate processing
idempotencyKey: ({ orderId }) => orderId,
})
export const NotificationWorkflow = Workflow.make({
name: "NotificationWorkflow",
payload: {
id: Schema.String, // Unique execution ID
messageId: MessageId,
channelId: ChannelId,
authorId: UserId,
},
idempotencyKey: ({ messageId }) => messageId,
})Workflow Implementation
import { Activity } from "@effect/workflow"
import { Effect } from "effect"
export const OrderFulfillmentWorkflowLayer = OrderFulfillmentWorkflow.toLayer(
Effect.fn("OrderFulfillmentWorkflow")(function* (payload) {
// Step 1: Reserve inventory
const reservation = yield* Activity.make({
name: "ReserveInventory",
success: InventoryReservation,
error: Schema.Union(InsufficientInventoryError, DatabaseError),
execute: Effect.gen(function* () {
const inventory = yield* InventoryService
return yield* inventory.reserve(payload.items)
}),
})
// Step 2: Process payment
const payment = yield* Activity.make({
name: "ProcessPayment",
success: PaymentResult,
error: Schema.Union(PaymentFailedError, PaymentTimeoutError),
execute: Effect.gen(function* () {
const payments = yield* PaymentService
return yield* payments.charge(payload.userId, payload.items)
}),
})
// Step 3: Create shipment
const shipment = yield* Activity.make({
name: "CreateShipment",
success: Shipment,
error: Schema.Union(ShippingError, AddressInvalidError),
execute: Effect.gen(function* () {
const shipping = yield* ShippingService
return yield* shipping.createShipment({
items: payload.items,
address: payload.shippingAddress,
reservationId: reservation.id,
})
}),
})
// Step 4: Send confirmation
yield* Activity.make({
name: "SendConfirmation",
success: Schema.Void,
error: NotificationError,
execute: Effect.gen(function* () {
const notifications = yield* NotificationService
yield* notifications.sendOrderConfirmation({
userId: payload.userId,
orderId: payload.orderId,
trackingNumber: shipment.trackingNumber,
})
}),
})
return { shipment, payment }
})
)Activity Patterns
Always include `success` and `error` schemas in Activity.make:
// CORRECT - schemas specified
yield* Activity.make({
name: "SendEmail",
success: EmailSentResult,
error: Schema.Union(EmailDeliveryError, EmailTemplateError),
execute: Effect.gen(function* () {
// Implementation
return { messageId: "msg-123", sentAt: new Date() }
}),
})
// WRONG - missing schemas
yield* Activity.make({
name: "SendEmail",
execute: Effect.gen(function* () {
// This will not serialize properly across workflow restarts
}),
})Activity Error Handling with Retryable
export class ExternalApiError extends Schema.TaggedError<ExternalApiError>()(
"ExternalApiError",
{
message: Schema.String,
statusCode: Schema.Number,
retryable: Schema.Boolean,
},
) {
static fromResponse(response: Response): ExternalApiError {
return new ExternalApiError({
message: `API error: ${response.statusText}`,
statusCode: response.status,
retryable: response.status >= 500, // 5xx errors are retryable
})
}
}
yield* Activity.make({
name: "CallExternalApi",
success: ApiResponse,
error: ExternalApiError,
execute: Effect.gen(function* () {
const response = yield* fetch(url)
if (!response.ok) {
return yield* Effect.fail(ExternalApiError.fromResponse(response))
}
return yield* response.json()
}),
})ClusterCron for Scheduled Jobs
import { ClusterCron } from "@effect/cluster"
export const DailyReportCron = ClusterCron.make({
name: "DailyReportCron",
// Cron expression: every day at 6 AM UTC
schedule: "0 6 * * *",
})
// Implementation
export const DailyReportCronLayer = DailyReportCron.toLayer(
Effect.fn("DailyReportCron")(function* () {
yield* Effect.log("Starting daily report generation")
const reports = yield* ReportService
yield* reports.generateDailyReport()
yield* Effect.log("Daily report generation complete")
})
)Triggering Workflows
From HTTP Handler
import { HttpApi, HttpApiEndpoint } from "@effect/platform"
const createOrder = HttpApiEndpoint.post("createOrder", "/orders")
.setPayload(CreateOrderInput)
.addSuccess(Order)
.addError(ValidationError)
// Handler triggers workflow
const createOrderHandler = Effect.gen(function* () {
const input = yield* HttpApi.payload
const workflowClient = yield* WorkflowClient
// Create order in database
const order = yield* OrderService.create(input)
// Trigger async fulfillment workflow
yield* workflowClient.workflows.OrderFulfillmentWorkflow.execute({
id: order.id,
orderId: order.id,
userId: input.userId,
items: input.items,
shippingAddress: input.shippingAddress,
})
return order
})From Backend Service
export class MessageService extends Effect.Service<MessageService>()("MessageService", {
accessors: true,
dependencies: [MessageRepo.Default, WorkflowClient.Default],
effect: Effect.gen(function* () {
const repo = yield* MessageRepo
const workflows = yield* WorkflowClient
const create = Effect.fn("MessageService.create")(function* (input: CreateMessageInput) {
const message = yield* repo.create(input)
// Trigger notification workflow
yield* workflows.workflows.NotificationWorkflow.execute({
id: message.id,
messageId: message.id,
channelId: message.channelId,
authorId: message.authorId,
})
return message
})
return { create }
}),
}) {}Workflow HTTP API
// Expose workflow execution via HTTP
const executeWorkflow = HttpApiEndpoint.post("executeWorkflow", "/workflows/:name/execute")
.setPath(Schema.Struct({ name: Schema.String }))
.setPayload(Schema.Unknown)
.addSuccess(Schema.Struct({ executionId: Schema.String }))
.addError(WorkflowNotFoundError)
// Handler
const executeWorkflowHandler = Effect.gen(function* () {
const { name } = yield* HttpApi.path
const payload = yield* HttpApi.payload
const client = yield* WorkflowClient
const workflow = client.workflows[name]
if (!workflow) {
return yield* Effect.fail(new WorkflowNotFoundError({ name }))
}
const result = yield* workflow.execute(payload)
return { executionId: payload.id }
})Schema Patterns
Branded Types for IDs
Always brand entity IDs to prevent accidentally passing the wrong ID type:
import { Schema } from "effect"
// Entity IDs - always branded with namespace
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 OrderId = Schema.UUID.pipe(Schema.brand("@App/OrderId"))
export type OrderId = Schema.Schema.Type<typeof OrderId>
export const ProductId = Schema.UUID.pipe(Schema.brand("@App/ProductId"))
export type ProductId = Schema.Schema.Type<typeof ProductId>Branding Convention
Use @Namespace/EntityName format:
@App/UserId- Main application entities@Billing/InvoiceId- Billing domain entities@External/StripeCustomerId- External system IDs
Creating Branded Values
// From string (validates UUID format)
const userId = Schema.decodeSync(UserId)("123e4567-e89b-12d3-a456-426614174000")
// Generate new ID
const newUserId = UserId.make(crypto.randomUUID())
// Type error - can't mix ID types
const order = yield* orderService.findById(userId) // Error: UserId is not OrderIdWhen NOT to Brand
Don't brand simple strings that don't need type safety:
// NOT branded - acceptable
export const Url = Schema.String
export const FilePath = Schema.String
export const EmailAddress = Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
// These don't need branding because:
// 1. They don't cross service boundaries in ways that could be confused
// 2. They're typically validated by format, not by typeSchema.Struct for Domain Types
Prefer Schema.Struct over TypeScript interfaces for domain types:
// CORRECT - Schema.Struct
export const User = Schema.Struct({
id: UserId,
email: Schema.String,
name: Schema.String,
organizationId: OrganizationId,
role: Schema.Literal("admin", "member", "viewer"),
createdAt: Schema.DateTimeUtc,
updatedAt: Schema.DateTimeUtc,
})
export type User = Schema.Schema.Type<typeof User>
// Can derive encoded type for database/API
export type UserEncoded = Schema.Schema.Encoded<typeof User>Input Types for Mutations
export const CreateUserInput = Schema.Struct({
email: Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
Schema.annotations({ description: "Valid email address" }),
),
name: Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(100),
),
organizationId: OrganizationId,
role: Schema.optionalWith(
Schema.Literal("admin", "member", "viewer"),
{ default: () => "member" as const }
),
})
export type CreateUserInput = Schema.Schema.Type<typeof CreateUserInput>
export const UpdateUserInput = Schema.Struct({
name: Schema.optional(Schema.String.pipe(Schema.minLength(1))),
role: Schema.optional(Schema.Literal("admin", "member", "viewer")),
})
export type UpdateUserInput = Schema.Schema.Type<typeof UpdateUserInput>Schema.transform and transformOrFail
Use transforms instead of manual parsing:
// Transform string to Date
export const DateFromString = Schema.transform(
Schema.String,
Schema.DateTimeUtc,
{
decode: (s) => new Date(s),
encode: (d) => d.toISOString(),
}
)
// Transform with validation (can fail)
export const PositiveNumber = Schema.transformOrFail(
Schema.Number,
Schema.Number.pipe(Schema.brand("PositiveNumber")),
{
decode: (n, _, ast) =>
n > 0
? ParseResult.succeed(n as Schema.Schema.Type<typeof PositiveNumber>)
: ParseResult.fail(new ParseResult.Type(ast, n, "Must be positive")),
encode: ParseResult.succeed,
}
)Common Transforms
// JSON string to object
export const JsonFromString = <A, I>(schema: Schema.Schema<A, I>) =>
Schema.transform(
Schema.String,
schema,
{
decode: (s) => JSON.parse(s),
encode: (a) => JSON.stringify(a),
}
)
// Comma-separated string to array
export const CommaSeparatedList = Schema.transform(
Schema.String,
Schema.Array(Schema.String),
{
decode: (s) => s.split(",").map((x) => x.trim()).filter(Boolean),
encode: (arr) => arr.join(","),
}
)
// Cents to dollars
export const DollarsFromCents = Schema.transform(
Schema.Number.pipe(Schema.int()),
Schema.Number,
{
decode: (cents) => cents / 100,
encode: (dollars) => Math.round(dollars * 100),
}
)Schema.Class for Entities with Methods
Use Schema.Class when entities need methods:
export class User extends Schema.Class<User>("User")({
id: UserId,
email: Schema.String,
name: Schema.String,
role: Schema.Literal("admin", "member", "viewer"),
createdAt: Schema.DateTimeUtc,
}) {
get isAdmin(): boolean {
return this.role === "admin"
}
get displayName(): string {
return this.name || this.email.split("@")[0]
}
canAccessResource(resource: Resource): boolean {
if (this.isAdmin) return true
return resource.ownerId === this.id
}
}
// Usage
const user = new User({
id: UserId.make(crypto.randomUUID()),
email: "alice@example.com",
name: "Alice",
role: "member",
createdAt: new Date(),
})
console.log(user.displayName) // "Alice"
console.log(user.isAdmin) // falseSchema.annotations
Add annotations for documentation and validation messages:
export const CreateOrderInput = Schema.Struct({
productId: ProductId.pipe(
Schema.annotations({ description: "The product to order" }),
),
quantity: Schema.Number.pipe(
Schema.int(),
Schema.positive(),
Schema.annotations({
description: "Number of items to order",
examples: [1, 5, 10],
}),
),
shippingAddress: Schema.Struct({
line1: Schema.String.pipe(Schema.annotations({ description: "Street address" })),
line2: Schema.optional(Schema.String),
city: Schema.String,
state: Schema.String.pipe(Schema.length(2)),
zip: Schema.String.pipe(Schema.pattern(/^\d{5}(-\d{4})?$/)),
}).pipe(Schema.annotations({ description: "Shipping destination" })),
}).pipe(
Schema.annotations({
title: "Create Order Input",
description: "Input for creating a new order",
}),
)Optional Fields
Use Schema.optional and Schema.optionalWith:
export const UserPreferences = Schema.Struct({
// Optional, undefined if not provided
theme: Schema.optional(Schema.Literal("light", "dark")),
// Optional with default value
language: Schema.optionalWith(Schema.String, { default: () => "en" }),
// Optional with null support (for database compatibility)
bio: Schema.NullOr(Schema.String),
// Optional but must be present if set (no undefined)
timezone: Schema.optional(Schema.String, { exact: true }),
})Union Types and Discriminated Unions
// Simple union
export const PaymentMethod = Schema.Union(
Schema.Literal("card"),
Schema.Literal("bank_transfer"),
Schema.Literal("crypto"),
)
// Discriminated union (tagged)
export const PaymentDetails = Schema.Union(
Schema.Struct({
_tag: Schema.Literal("Card"),
cardNumber: Schema.String,
expiry: Schema.String,
cvv: Schema.String,
}),
Schema.Struct({
_tag: Schema.Literal("BankTransfer"),
accountNumber: Schema.String,
routingNumber: Schema.String,
}),
Schema.Struct({
_tag: Schema.Literal("Crypto"),
walletAddress: Schema.String,
network: Schema.Literal("ethereum", "bitcoin", "solana"),
}),
)
export type PaymentDetails = Schema.Schema.Type<typeof PaymentDetails>
// Usage with match
const processPayment = (details: PaymentDetails) => {
switch (details._tag) {
case "Card":
return processCard(details.cardNumber, details.expiry, details.cvv)
case "BankTransfer":
return processBankTransfer(details.accountNumber, details.routingNumber)
case "Crypto":
return processCrypto(details.walletAddress, details.network)
}
}Enums and Literals
// Use Literal for small, fixed sets
export const UserRole = Schema.Literal("admin", "member", "viewer")
export type UserRole = Schema.Schema.Type<typeof UserRole>
// Use Enums for larger sets or when you need runtime values
export const OrderStatus = Schema.Enums({
Pending: "pending",
Processing: "processing",
Shipped: "shipped",
Delivered: "delivered",
Cancelled: "cancelled",
} as const)
export type OrderStatus = Schema.Schema.Type<typeof OrderStatus>Recursive Schemas
interface Category {
id: string
name: string
children: readonly Category[]
}
export const Category: Schema.Schema<Category> = Schema.Struct({
id: Schema.String,
name: Schema.String,
children: Schema.Array(Schema.suspend(() => Category)),
})Schema.is Type Guard
Schema.is(schema)→(u: unknown) => u is A— match without decode/allocate- Prefer over
Set.hasetc — schema = single source of truth
const isUserRole = Schema.is(UserRole)
if (isUserRole(input)) {
// input: "admin" | "member" | "viewer"
}Decoding and Encoding
// Decode (parse) - use in services
const parseUser = Schema.decodeUnknown(User)
const result = yield* parseUser(rawData) // Effect<User, ParseError>
// Decode sync - only in controlled contexts
const user = Schema.decodeUnknownSync(User)(rawData)
// Encode - for serialization
const encodeUser = Schema.encode(User)
const encoded = yield* encodeUser(user) // Effect<UserEncoded, ParseError>JSON strings: Schema.parseJson
Use Schema.parseJson(schema) instead of JSON.parse + manual decode. Handles invalid JSON (returns Left) and schema validation in one step.
// JSON string → validated A. No try/catch — parse errors become Left
const decoded = Schema.decodeUnknownEither(Schema.parseJson(MySchema))(jsonStr)
const value = Either.getOrElse(decoded, () => undefined)
// Sync decode (throws on invalid JSON or schema mismatch)
const config = Schema.decodeUnknownSync(Schema.parseJson(ConfigSchema))(jsonStr)Service Patterns
Effect.Service Over Context.Tag
Always prefer `Effect.Service` for defining business logic services. This is the modern, recommended approach that provides:
1. Built-in `Default` layer - No manual layer creation needed 2. Automatic accessors - Direct method calls via ServiceName.method() 3. Proper dependency declaration - Dependencies are explicit and type-checked 4. Consistent structure - All services follow the same pattern
Basic Service Definition
import { Effect, Layer } from "effect"
export class UserService extends Effect.Service<UserService>()("UserService", {
accessors: true,
effect: Effect.gen(function* () {
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
// Implementation
})
const findByEmail = Effect.fn("UserService.findByEmail")(function* (email: string) {
// Implementation
})
const create = Effect.fn("UserService.create")(function* (input: CreateUserInput) {
// Implementation
})
return { findById, findByEmail, create }
}),
}) {}Service with Dependencies
Critical: Always declare dependencies using the dependencies array. This ensures:
- Dependencies are automatically provided when using
ServiceName.Default - Type errors if dependencies are missing
- No manual
Layer.provideat usage sites
export class OrderService extends Effect.Service<OrderService>()("OrderService", {
accessors: true,
dependencies: [
UserService.Default,
ProductService.Default,
InventoryService.Default,
],
effect: Effect.gen(function* () {
// Dependencies are automatically available
const users = yield* UserService
const products = yield* ProductService
const inventory = yield* InventoryService
const create = Effect.fn("OrderService.create")(function* (input: CreateOrderInput) {
// Validate user exists
const user = yield* users.findById(input.userId)
// Check product availability
const product = yield* products.findById(input.productId)
const available = yield* inventory.checkAvailability(input.productId, input.quantity)
if (!available) {
return yield* Effect.fail(new InsufficientInventoryError({
productId: input.productId,
message: "Not enough inventory",
}))
}
// Create order...
})
return { create }
}),
}) {}Wrong: Leaking Dependencies
// WRONG - Dependencies not declared, must be provided manually
export class OrderService extends Effect.Service<OrderService>()("OrderService", {
accessors: true,
effect: Effect.gen(function* () {
const users = yield* UserService // Dependency not in `dependencies` array!
// ...
}),
}) {}
// Now every usage site must do this:
const program = OrderService.create(input).pipe(
Effect.provide(UserService.Default), // Annoying and error-prone
)Effect.fn for Tracing
Always wrap service methods with `Effect.fn`. This provides automatic tracing with meaningful span names.
Naming Convention
Use ServiceName.methodName format for span names:
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
yield* Effect.annotateCurrentSpan("userId", id)
// Implementation
})
const processPayment = Effect.fn("PaymentService.processPayment")(
function* (orderId: OrderId, amount: number, currency: string) {
yield* Effect.annotateCurrentSpan("orderId", orderId)
yield* Effect.annotateCurrentSpan("amount", amount)
yield* Effect.annotateCurrentSpan("currency", currency)
// Implementation
}
)Annotating Spans
Add important context to spans, but don't overdo it:
// CORRECT - Important business identifiers
yield* Effect.annotateCurrentSpan("userId", userId)
yield* Effect.annotateCurrentSpan("orderId", orderId)
yield* Effect.annotateCurrentSpan("amount", amount)
// WRONG - Too much detail, noise in traces
yield* Effect.annotateCurrentSpan("userEmail", user.email)
yield* Effect.annotateCurrentSpan("userName", user.name)
yield* Effect.annotateCurrentSpan("userCreatedAt", user.createdAt)
yield* Effect.annotateCurrentSpan("step", "validating")
yield* Effect.annotateCurrentSpan("step", "processing")
yield* Effect.annotateCurrentSpan("step", "completing")When Context.Tag is Acceptable
Context.Tag is appropriate only for infrastructure that's injected at runtime:
Cloudflare Worker Bindings
import { Context } from "effect"
// These are provided by the runtime, not created by our code
export class KVNamespace extends Context.Tag("KVNamespace")<
KVNamespace,
CloudflareKVNamespace
>() {}
export class R2Bucket extends Context.Tag("R2Bucket")<
R2Bucket,
CloudflareR2Bucket
>() {}
// In the worker entry point
const handler = {
fetch(request: Request, env: Env) {
return program.pipe(
Effect.provideService(KVNamespace, env.MY_KV),
Effect.provideService(R2Bucket, env.MY_BUCKET),
Effect.runPromise,
)
}
}Database/Redis Clients (Infrastructure)
// Infrastructure provided at app root - acceptable as Context.Tag
// But prefer using @effect/sql or similar typed clients
import { PgClient } from "@effect/sql-pg"
// PgClient is already a Context.Tag from the library
// Just provide it at the app root
const DatabaseLive = PgClient.layer({
host: Config.string("DB_HOST"),
port: Config.integer("DB_PORT"),
database: Config.string("DB_NAME"),
// ...
})Single Responsibility
Each service should have a focused responsibility:
// CORRECT - Focused services
export class UserService extends Effect.Service<UserService>()("UserService", { /* user operations */ }) {}
export class AuthService extends Effect.Service<AuthService>()("AuthService", { /* auth operations */ }) {}
export class NotificationService extends Effect.Service<NotificationService>()("NotificationService", { /* notifications */ }) {}
// WRONG - God service doing everything
export class AppService extends Effect.Service<AppService>()("AppService", {
effect: Effect.gen(function* () {
return {
createUser,
deleteUser,
login,
logout,
sendEmail,
sendPush,
processPayment,
// ... 50 more methods
}
}),
}) {}Service Interface Patterns
Return Types
Services should return Effect types, never Promise:
// CORRECT
const findById = Effect.fn("UserService.findById")(
function* (id: UserId): Effect.Effect<User, UserNotFoundError> {
// ...
}
)
// WRONG - Promise in service interface
const findById = async (id: UserId): Promise<User> => {
// ...
}Use Option for Nullable Results
// CORRECT - findById can fail, findByIdOption returns Option
const findById = Effect.fn("UserService.findById")(
function* (id: UserId): Effect.Effect<User, UserNotFoundError> {
const maybeUser = yield* repo.findById(id)
return yield* Option.match(maybeUser, {
onNone: () => Effect.fail(new UserNotFoundError({ userId: id, message: "Not found" })),
onSome: Effect.succeed,
})
}
)
const findByIdOption = Effect.fn("UserService.findByIdOption")(
function* (id: UserId): Effect.Effect<Option<User>> {
return yield* repo.findById(id)
}
)Testing Services
Create test implementations using the same pattern:
// Test implementation
export const UserServiceTest = Layer.succeed(
UserService,
UserService.of({
findById: (id) => Effect.succeed(mockUser),
create: (input) => Effect.succeed({ ...mockUser, ...input }),
})
)
// Or with Effect.Service for stateful mocks
export class UserServiceTest extends Effect.Service<UserService>()("UserService", {
accessors: true,
effect: Effect.gen(function* () {
const users = new Map<string, User>()
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
const user = users.get(id)
if (!user) return yield* Effect.fail(new UserNotFoundError({ userId: id, message: "Not found" }))
return user
})
const create = Effect.fn("UserService.create")(function* (input: CreateUserInput) {
const user = { id: UserId.make(crypto.randomUUID()), ...input }
users.set(user.id, user)
return user
})
return { findById, create }
}),
}) {}