Skip to content

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, andThen or zipWith inside a .pipe that takes the previous value.
  • .pipe applies combinators to one effect: map, as, catchTag(s), mapError, retry, timeout, tap*, provide*, orDie. No step limit is needed.
  • The rule covers Effect values in feature code. Layer assembly, Stream pipelines and Schema / Config builders are pipelines by nature.
  • Accepted cost: a two-step flow that would fit in one flatMap takes 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 / fnUntraced body holds the steps in order, with no error handling. Its pipeable arguments hold catchTag(s), mapError, retry and timeout.
  • 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.catch catch-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 transaction

Source: 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.fn without a name. The unnamed form makes no span but reads like the named one, and it still allocates an Error per 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 traced
const 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 => ({ … }) // pure

Source: notes/05-effect-fundamentals/gen-vs-pipe.md · Decision 3

Impact: MEDIUM every call otherwise yields two identical spans

  • A named Effect.fn already creates the span, and its pipeables run inside it. A withSpan with 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 through Effect.annotateCurrentSpan in the body.
  • The one case for an Effect.withSpan pipeable is fnUntraced with 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

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), a Match or ternary arm, or a local const yielded later.
  • A generator inside that nested one becomes a fnUntraced helper in make, taking what it closed over as parameters. The service’s make generator 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

  • A lint rule app/effect-fn-named rejecting Effect.fn( without a name and Effect.withSpan inside an Effect.fn — trigger: the first unnamed Effect.fn, or the first doubled span, found in our code by review or in a trace.