Effect Advanced: Patterns, Conventions & Pitfalls
This skill defines the rules, conventions, and architectural decisions for building
production Effect-TS applications. It is intentionally opinionated to prevent common
pitfalls and enforce patterns that scale.
For detailed API documentation, use other appropriate tools (documentation lookup,
web search, etc.) — this skill focuses on how and why to use Effect idiomatically,
not the full API surface.
Table of Contents
- Core Conventions
- Error Handling Philosophy
- Dependency Injection Architecture
- Resource & Scope Rules
- Concurrency Model
- Common Pitfalls
- Reference Files
Core Conventions
Use for business logic
Generators read like synchronous code and are strongly preferred over long
/
chains for anything beyond trivial composition:
typescript
const program = Effect.gen(function* () {
const config = yield* ConfigService;
const user = yield* UserRepo.findById(config.userId);
return user;
});
Reserve
for data transformation pipelines and short combinator chains.
Never throw — use Effect's error channel
| Instead of... | Use |
|---|
| Effect.fail(new MyError())
|
| on promises | Effect.tryPromise({ try, catch })
|
| Callback APIs | Effect.async((resume) => ...)
|
| Unrecoverable crashes | |
Functions over methods
Prefer
over
for composability and
tree-shaking. Flat imports (
import { Effect } from "effect"
) are fine for
applications; namespace imports (
import * as Effect from "effect/Effect"
) are
better for libraries.
is deprecated
Schema has been merged into core
. Import from
directly:
typescript
import { Schema } from "effect";
// NOT: import { Schema } from "@effect/schema"
Use in production
does not handle
/
gracefully:
typescript
import { NodeRuntime } from "@effect/platform-node";
NodeRuntime.runMain(program.pipe(Effect.provide(AppLayer)));
Error Handling Philosophy
Failures vs defects — the fundamental distinction
| Aspect | Failure (expected) | Defect (unexpected) |
|---|
| API | Effect.fail(new MyError())
| |
| Type channel | Tracked in | Never appears in () |
| Recovery | , , | Only at system boundaries |
| Rule of thumb | You intend to handle it at call site | Bug or impossible state |
Always use tagged errors
Plain
or string failures miss the value of Effect's typed error channel:
typescript
class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: string;
}> {}
// Tagged errors are yieldable — no Effect.fail wrapper needed
const program = Effect.gen(function* () {
const user = yield* db.findUser(id);
if (!user) yield* new UserNotFound({ id });
return user;
});
does NOT catch defects
This is the #1 error handling mistake:
typescript
Effect.catchAll(program, handler); // catches E only — NOT defects
Effect.catchAllCause(program, handler); // catches everything (E + defects + interrupts)
Only use
/
at system boundaries (top-level error
handlers, HTTP response mappers).
Dependency Injection Architecture
Service → Layer → Provide (once)
text
1. Define services with Context.Tag → "what do I need?"
2. Implement via Layers → "how is it built?"
3. Provide once at entry point → "wire it all together"
Service methods must have
Dependencies belong in Layer composition, not method signatures:
typescript
// WRONG: leaks dependency to callers
findById: (id: string) => Effect.Effect<User, UserNotFound, Database>;
// RIGHT: Database is wired in the Layer
findById: (id: string) => Effect.Effect<User, UserNotFound>;
Layer composition — know the operators
| Operation | When | Behavior |
|---|
| Independent services | Both build concurrently |
Layer.provide(downstream, upstream)
| A feeds B | upstream builds first |
| Force new instance | Bypasses memoization |
Critical: does NOT sequence construction. If B depends on A, use
, not
.
One at the entry point
Scattered
calls create hidden dependencies and layer duplication:
typescript
// WRONG: provide scattered throughout codebase
const getUser = UserRepo.findById(id).pipe(Effect.provide(DbLayer));
// RIGHT: compose and provide once
const main = program.pipe(Effect.provide(AppLayer));
NodeRuntime.runMain(main);
Resource & Scope Rules
is mandatory for
Forgetting
is the #1 resource management pitfall — resources
accumulate until the program exits:
typescript
// WRONG: scope never closes, connection leaks
const result = yield * getDbConnection;
// RIGHT: scope closes when block completes
const result =
yield *
Effect.scoped(
Effect.gen(function* () {
const conn = yield* getDbConnection;
return yield* conn.query("SELECT 1");
}),
);
Release finalizers always run
On success, failure, AND interruption — guaranteed. The finalizer receives the
value for conditional cleanup.
Multiple resources in one scope
typescript
Effect.scoped(
Effect.gen(function* () {
const conn = yield* Effect.acquireRelease(openConn(), closeConn);
const file = yield* Effect.acquireRelease(openFile(), closeFile);
// both released when scope closes, in REVERSE acquisition order
}),
);
Concurrency Model
Prefer high-level APIs over raw fork
| API | Use case |
|---|
Effect.all([], { concurrency: N })
| Bounded parallel execution |
Effect.forEach(items, fn, { concurrency: N })
| Worker pool pattern |
| First to complete wins, others interrupted |
| Deadline on any effect |
Only reach for
/
when high-level APIs are insufficient.
Fork variants — know the lifecycle
| Function | Scope | Cleanup |
|---|
| Parent's scope | Auto-interrupted with parent |
| Global scope | Nothing cleans it up — you must |
| Nearest Scope | Tied to resource lifecycle |
Gotcha: leaks fibers if you forget to interrupt them.
Common Pitfalls
-
Floating effects — creating an Effect without yielding or running it is a silent
bug.
inside a generator does nothing unless
-ed.
-
won't catch defects — use
at system boundaries for
full failure visibility.
-
Missing —
without a scope boundary leaks resources
until program exit.
-
Scattered — compose all layers and provide once at the entry point.
-
Point-free on overloaded functions —
Effect.map(myOverloadedFn)
silently erases
generics. Use explicit lambdas:
Effect.map((x) => myOverloadedFn(x))
.
-
resume called multiple times — resume must be called exactly once.
Multiple calls cause undefined behavior.
-
silences errors — converts typed failures to untyped defects. Handle errors
properly instead.
-
for dependent services — merge doesn't sequence construction. Use
when one layer needs another's output.
-
vs —
can cause premature finalizer execution in
edge cases. Prefer
when resource safety matters.
-
on infinite streams — never call without a prior
. It will
never terminate and consume unbounded memory.
-
Using for scoped tests — effects requiring
must use
,
not
, or you get a type error.
Reference Files
Read the relevant reference file when working with a specific concern:
| File | When to read |
|---|
references/error-handling.md
| Tagged errors, Cause, defect recovery, error mapping patterns |
references/dependency-injection.md
| Services, Layers, composition, memoization, provide patterns |
references/concurrency.md
| Fibers, fork variants, Deferred, Semaphore, structured concurrency |
references/resource-management.md
| Scope, acquireRelease, Layer resources, fork + scope interaction |
| Schema definition, transforms, branded types, recursive schemas |
| Stream operators, chunking, backpressure, resourceful streams |
| @effect/vitest, TestClock, Layer mocking, Config mocking |
| HTTP client, FileSystem, Command, runtime, framework integration |