Effect.gen vs pipe, and Effect.fn
Every repo writes generators by default, but none writes down where .pipe and error handling
belong. This page answers when to use Effect.gen and when .pipe, where error handling
sits inside an Effect.fn, and what form a private helper takes.
Sequence with Effect.gen; use .pipe to decorate one effect
Section titled “Sequence with Effect.gen; use .pipe to decorate one effect”Impact: MEDIUM a yes/no rule, unlike a step limit
- A step that needs the value of an earlier step is written in a generator. In business code there
is no
Effect.flatMap,andThenorzipWithinside a.pipethat takes the previous value. .pipeapplies combinators to one effect:map,as,catchTag(s),mapError,retry,timeout,tap*,provide*,orDie. No step limit is needed.- The rule covers
Effectvalues in feature code.Layerassembly,Streampipelines andSchema/Configbuilders are pipelines by nature. - Accepted cost: a two-step flow that would fit in one
flatMaptakes four lines of generator.
❌ Incorrect — sequencing hidden in a pipe:
getRule: ({ params }) => rules.get(params.id).pipe( Effect.catch(() => new Http.NotFound({ resource: "rule", id: params.id })), Effect.flatMap((rule) => audit.recordRead(rule.id).pipe(Effect.as(rule))), Effect.flatMap(encode),)✅ Correct — a decorating pipe on one call (the thin handler from HttpApi):
create: ({ payload }) => rules.create(payload).pipe( Effect.map(toPublicRule), Effect.catchTags({ /* domain → wire errors */ }, internalFailure),)Source: notes/05-effect-fundamentals/gen-vs-pipe.md · Decision 1
In an Effect.fn, the body is the happy path and the pipeables are its errors
Section titled “In an Effect.fn, the body is the happy path and the pipeables are its errors”Impact: HIGH a method’s error translation reads in one place
- The
Effect.fn/fnUntracedbody holds the steps in order, with no error handling. Its pipeable arguments holdcatchTag(s),mapError,retryandtimeout. - Pipeables receive the call’s arguments: write
(E, input) => …, not a closure. - Pipeables run inside the method’s span. A retry shows as one span over all attempts, and an
error recovered in a pipeable ends the span
Ok. Recover where the decision belongs. - A pipeable is not a terminal boundary: catch by tag, no
Effect.catchcatch-all in a service (see error boundaries).
❌ Incorrect — error handling interleaved with the steps, the retry wrapped around one yield:
create: Effect.fn("LabelingRules.create")(function* (input: CreateLabelingRuleInput) { const org = yield* CurrentOrg const label = yield* github.validateLabel(input.label).pipe( Effect.catchTag("@app/github/LabelMissing", () => new RuleLabelInvalid({ label: input.label })), ) return yield* repo.insert(org.orgId, toRule(input, label)).pipe( Effect.retry({ while: isRetryablePersistence, times: 2 }), )}),✅ Correct — the steps in the body, everything else in the pipeable:
create: Effect.fn("LabelingRules.create")( function* (input: CreateLabelingRuleInput) { const org = yield* CurrentOrg // the tenant, from R const normalized = yield* normalize(input) const label = yield* github.validateLabel(normalized.label) const rule = yield* repo.insert(org.orgId, toRule(normalized, label)) yield* Effect.annotateCurrentSpan({ "app.labeling.rule_id": rule.id }) return rule }, (E, input) => E.pipe( Effect.retry({ while: isRetryablePersistence, times: 2 }), Effect.catchTag("@app/github/LabelMissing", () => new RuleLabelInvalid({ label: input.label })), ),),Source: notes/05-effect-fundamentals/gen-vs-pipe.md · Decision 2, amended
Handle inline only for a divergent strategy or an invariant
Section titled “Handle inline only for a divergent strategy or an invariant”Impact: MEDIUM a pipeable catch also catches yields added later
- A pipeable may catch a tag only if exactly one yield in the body produces it. A PR that adds a second yield producing an already-caught tag must move the catch inline.
- Exactly two cases are written inline: a divergent strategy (the same tag from two yields needs
different handling) and an invariant
orDie, with its comment. - Accepted cost: the reader looks in two places, body and pipeable.
❌ Incorrect — one pipeable catch silently covers two different yields:
function* (input) { const source = yield* github.getLabel(input.label) const fallback = yield* github.getLabel(input.fallbackLabel) // same tag, different meaning},(E, input) => E.pipe( Effect.catchTag("@app/github/LabelMissing", () => new RuleLabelInvalid({ label: input.label })),)✅ Correct — each yield handles its own:
const source = yield* github.getLabel(input.label).pipe( Effect.catchTag("@app/github/LabelMissing", () => new RuleLabelInvalid({ label: input.label })),)const fallback = yield* github.getLabel(input.fallbackLabel).pipe( Effect.catchTag("@app/github/LabelMissing", () => Effect.succeed(defaultLabel)),)const stored = yield* Effect.fromOption(yield* repo.findById(org.orgId, ruleId), () => new StoredRuleMissing({ ruleId })) .pipe(Effect.orDie) // invariant: inserted earlier in this transactionSource: notes/05-effect-fundamentals/gen-vs-pipe.md · Decision 2
Write private helpers as fnUntraced or a plain arrow
Section titled “Write private helpers as fnUntraced or a plain arrow”Impact: MEDIUM a name on Effect.fn then always means a span
| the function | its form |
|---|---|
| public service method | Effect.fn("Service.method") |
| private helper with steps | Effect.fnUntraced(function* (…) { … }) |
| private helper that is one expression | a plain arrow returning an Effect |
| HttpApi handler, group builder | one expression: plain arrow; with steps: fnUntraced |
| pure logic | a plain function, no Effect |
- No
Effect.fnwithout a name. The unnamed form makes no span but reads like the named one, and it still allocates anErrorper call. - No function whose body is
return Effect.gen(…). A callback passed inline to a combinator is not a function definition. Which functions get a span is decided in observability.
❌ Incorrect — an unnamed fn and a wrapper around Effect.gen:
const normalize = Effect.fn(function* (input: CreateLabelingRuleInput) { … }) // no span, looks tracedconst listEnabled = (orgId: OrgId, repositoryId: RepositoryId) => Effect.gen(function* () { const rules = yield* repo.listByRepository(orgId, repositoryId) return rules.filter(isEnabled)})✅ Correct — fnUntraced for steps, an arrow for one expression:
const normalize = Effect.fnUntraced(function* (input: CreateLabelingRuleInput) { … })const listEnabled = (orgId: OrgId, repositoryId: RepositoryId) => // a repository takes orgId first repo.listByRepository(orgId, repositoryId).pipe(Effect.map(Array.filter(isEnabled)))const toRule = (input: NormalizedInput, label: GitHubLabel): LabelingRule => ({ … }) // pureSource: notes/05-effect-fundamentals/gen-vs-pipe.md · Decision 3
Never add withSpan inside an Effect.fn
Section titled “Never add withSpan inside an Effect.fn”Impact: MEDIUM every call otherwise yields two identical spans
- A named
Effect.fnalready creates the span, and its pipeables run inside it. AwithSpanwith the same name opens a second, identical child span on every call. - Static attributes go in
Effect.fn("X", { attributes }). Attributes that depend on an argument go throughEffect.annotateCurrentSpanin the body. - The one case for an
Effect.withSpanpipeable isfnUntracedwith behaviour that must sit outside the span. No such case exists in our backend today.
❌ Incorrect — the method’s span opened twice:
list: Effect.fn("LabelingRules.list")( function* (repositoryId: RepositoryId) { … }, (E, repositoryId) => E.pipe( Effect.withSpan("LabelingRules.list", { attributes: { repositoryId } }), ),),✅ Correct — one span, attribute set from the body:
list: Effect.fn("LabelingRules.list")(function* (repositoryId: RepositoryId) { yield* Effect.annotateCurrentSpan({ "app.labeling.repository_id": repositoryId }) …}),Source: notes/05-effect-fundamentals/gen-vs-pipe.md · Decision 3
Nest a generator one level at most
Section titled “Nest a generator one level at most”Impact: LOW depth is what makes Effect code hard to read
- One nested generator inside a function body is allowed: an inline callback (
forEach,Stream), aMatchor ternary arm, or a localconstyielded later. - A generator inside that nested one becomes a
fnUntracedhelper inmake, taking what it closed over as parameters. The service’smakegenerator does not count as a level. - A nested generator follows the same rules: no catch-log-die inside it.
- Accepted cost: a loop inside a loop forces a named, single-use helper.
❌ Incorrect — a generator two levels deep:
yield* Effect.forEach(input.labels, (label) => Effect.gen(function* () { const found = yield* github.getLabel(label) yield* Effect.forEach(found.aliases, (alias) => Effect.gen(function* () { /* level 2 */ })) }))✅ Correct — level 2 becomes a helper in make:
const recordAliases = Effect.fnUntraced(function* (found: GitHubLabel) { … })
yield* Effect.forEach(input.labels, (label) => Effect.gen(function* () { // level 1: allowed const found = yield* github.getLabel(label) yield* recordAliases(found) }))Source: notes/05-effect-fundamentals/gen-vs-pipe.md · Decision 4
Deferred
Section titled “Deferred”- A lint rule
app/effect-fn-namedrejectingEffect.fn(without a name andEffect.withSpaninside anEffect.fn— trigger: the first unnamedEffect.fn, or the first doubled span, found in our code by review or in a trace.