04·03Effect.fn

Creates a named, traced wrapper around a generator-style Effect. Every invocation opens a span carrying the supplied name; traces, logs, and error reports include that name instead of `<anonymous>`.

27 - Effect.fn: Traced, Named Effectful Functions

Previous · Next

Creates a named, traced wrapper around a generator-style Effect. Every invocation opens a span carrying the supplied name; traces, logs, and error reports include that name instead of <anonymous>.

An optional second argument transforms the entire effect, the natural home for retries, timeouts, and other call-wide instrumentation.

What you’ll work through

index.test.ts contains three beats: declaring a named Effect.fn, threading typed errors through its E channel, and using the second argument to attach call-wide instrumentation (retry, timeout, log annotations).

By the end you should wrap every service method in Effect.fn("Name.method") without thinking about it. The convention is canonical — step 23 documents the discipline.


The Shape

Effect.fn("name")(
function* (args) { ... yield* something ... },
// optional: wraps every call
(effect, ...args) => effect.pipe(Effect.retry(...), Effect.timeout(...)),
)

The first call binds the name. The second call takes the generator and any number of pipeable instrumentation steps.

The result is a regular function returning an Effect.


A Named Function

import { Effect } from "effect"
// ┌─── (n: number) => Effect<number, never, never>
// ▼
const double = Effect.fn("double")(function* (n: number) {
return yield* Effect.succeed(n * 2)
})
Effect.runSync(double(5)) // 10

Details

Without Effect.fn, the same logic written as const double = (n: number) => Effect.gen(...) produces an anonymous span. With it, every trace edge carries the string "double", visible in OpenTelemetry, error messages, and the structured log.

When to Use

For every top-level effectful function that crosses a boundary: service method implementations, request handlers, scheduled jobs, anything you might need to find in a trace.


A Named Function with Failure

import { Data, Effect } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{
readonly url: string
}> {}
// ┌─── (id: string) => Effect<User, FetchError, never>
// ▼
const fetchUser = Effect.fn("fetchUser")(function* (id: string) {
if (!id.startsWith("user_")) {
return yield* Effect.fail(new FetchError({ url: `/api/users/${id}` }))
}
return yield* Effect.tryPromise({
try: async () => ({ id, name: "Alice" }),
catch: (cause) => new FetchError({ url: `/api/users/${id}` }),
})
})

The failure carries the function name in its span. Log lines from fetchUser’s span are correlated automatically.


Centralised Instrumentation

The second argument to Effect.fn runs once per call and wraps the whole effect. This is where retry, timeout, and similar policies belong, applied at the definition site so every call gets them.

import { Effect, Schedule } from "effect"
const resilientFetch = Effect.fn("resilientFetch")(
function* (id: string) {
return yield* Effect.tryPromise({
try: () => fetchFromApi(id),
catch: (cause) => new FetchError({ url: id, cause }),
})
},
(effect) =>
effect.pipe(
Effect.retry(Schedule.recurs(3)),
Effect.timeout("5 seconds"),
),
)

Every call to resilientFetch gets the same retry policy and the same timeout. Call sites remain clean.


Effect.fn vs const

// Anonymous, works, but the trace shows <anonymous>.
const processOrder = (orderId: string) =>
Effect.gen(function* () {
const db = yield* DbService
return yield* db.find(orderId)
})
// Named, same behaviour, observable.
const processOrder = Effect.fn("processOrder")(function* (orderId: string) {
const db = yield* DbService
return yield* db.find(orderId)
})

Runtime behaviour is identical. The difference is debuggability.


Common Pitfalls

1, Calling the factory directly. Effect.fn("name")(gen) returns a function, not an effect. Invoke it with the arguments to obtain the effect: myFn(args).pipe(...).

2, Pushing partial logic into the instrumentation argument. The second argument wraps the whole call. To apply retry only to one inner step, pipe that step directly, do not lift partial logic into the wrapper.

3, Reaching for Effect.fnUntraced. Skip the span only when measurements show the overhead matters in a hot loop. Default to Effect.fn.

4, Naming generically. Effect.fn("handler") is little better than anonymous. Use the function’s actual responsibility: "processOrder", "reserveInventory", "sendReceipt".


label is the supplied name, not ``. - [ ] Thread a typed error through the `E` channel — the `fn` wrapper preserves it. - [ ] Use the second argument to attach call-wide instrumentation (retry, timeout, log annotation) without touching the body.

See Also

  • Previous, 18-schema-modeling: data validation at the boundary.
  • Next, 33-source-patterns: combinators you’ll see in real code.
  • Foundations, 07-effect-gen for the generator form, 32-retry-and-timeout for common instrumentation.
  • Later, 58-observability for tracing and structured logging.

Run the tests: pnpm test 27-effect-fn


The full test suite

The index.test.ts for this lesson, verbatim from steps/27-effect-fn/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import { Effect, Schedule, Data } from "effect"
// =============================================================================
// 25 - Effect.fn: Traced, Named Effectful Functions
// =============================================================================
//
// Effect.fn("name")(gen) wraps a generator with a named span.
// Effect.fn("name")(gen, (eff) => eff.pipe(...)) adds call-wide instrumentation.
describe("25 - Effect.fn", () => {
// @beat
// id: named-fn
// shape: one-todo
// title: Name and trace an effectful function
// teaches: Effect.fn("name")(gen) wraps a generator so every invocation opens a span carrying that name.
// hint: const double = Effect.fn("double")(function* (n: number) { return yield* Effect.succeed(n * 2) })
describe("named functions", () => {
it("creates a function that returns an Effect", async () => {
// ┌─── (n: number) => Effect<number, never, never>
// ▼
const double = Effect.fn("double")(function* (n: number) {
return yield* Effect.succeed(n * 2)
})
expect(await Effect.runPromise(double(5))).toBe(10)
})
it("attaches the name to every invocation's span", async () => {
const greet = Effect.fn("greet")(function* (name: string) {
return `Hello, ${name}!`
})
expect(await Effect.runPromise(greet("World"))).toBe("Hello, World!")
})
it("supports yielding async effects", async () => {
const fetchName = Effect.fn("fetchName")(function* (id: string) {
return yield* Effect.tryPromise({
try: async () => ({ id, name: "Alice" }),
catch: (cause) => new Error(`Fetch failed: ${cause}`),
})
})
const result = await Effect.runPromise(fetchName("123"))
expect(result.name).toBe("Alice")
})
it("is a drop-in for const fn = () => Effect.gen", async () => {
const anonymous = (n: number, factor: number) =>
Effect.gen(function* () {
return yield* Effect.succeed(n * factor)
})
const named = Effect.fn("multiply")(function* (n: number, factor: number) {
return yield* Effect.succeed(n * factor)
})
expect(await Effect.runPromise(anonymous(4, 5))).toBe(20)
expect(await Effect.runPromise(named(4, 5))).toBe(20)
})
})
// @end-beat
// @beat
// id: fn-error-channel
// shape: one-todo
// title: Failures inherit the function name
// teaches: Inside Effect.fn the error channel works unchanged; the failure carries the function name in its span.
// hint: yield* Effect.fail(new FetchError({ url })) inside the generator
describe("error handling inside Effect.fn", () => {
class FetchError extends Data.TaggedError("FetchError")<{
readonly url: string
}> {}
const fetchData = Effect.fn("fetchData")(function* (url: string) {
if (url.includes("error")) {
return yield* Effect.fail(new FetchError({ url }))
}
return yield* Effect.succeed({ data: "ok" })
})
it("failures carry the function name in their span", async () => {
const exit = await Effect.runPromiseExit(fetchData("/api/error"))
expect(exit._tag).toBe("Failure")
})
it("the success path is unaffected", async () => {
const result = await Effect.runPromise(fetchData("/api/users"))
expect(result.data).toBe("ok")
})
})
// @end-beat
// @beat
// id: call-wide-instrumentation
// shape: multi-todo
// title: Centralise retry and timeout at the definition site
// teaches: The second argument of Effect.fn wraps every call uniformly — retry and timeout policies live there, not at call sites.
// hint: Effect.fn("name")(function* (...) { ... }, (effect) => effect.pipe(Effect.retry(Schedule.recurs(3))))
describe("call-wide instrumentation (second argument)", () => {
it("retry policies live at the definition site", async () => {
let attempts = 0
const resilientFetch = Effect.fn("resilientFetch")(
function* (id: string) {
attempts++
if (attempts < 3) {
return yield* Effect.fail("transient")
}
return yield* Effect.succeed({ id, name: "Alice" })
},
(effect) => effect.pipe(Effect.retry(Schedule.recurs(3))),
)
const result = await Effect.runPromise(resilientFetch("123"))
expect(result.name).toBe("Alice")
expect(attempts).toBe(3)
})
it("every caller gets the same instrumentation", async () => {
let callCount = 0
const withOneRetry = Effect.fn("withOneRetry")(
function* (x: number) {
callCount++
if (callCount === 1) {
return yield* Effect.fail("first attempt fails")
}
return yield* Effect.succeed(x * 10)
},
(effect) => effect.pipe(Effect.retry(Schedule.recurs(1))),
)
const result = await Effect.runPromise(withOneRetry(7))
expect(result).toBe(70)
expect(callCount).toBe(2)
})
})
// @end-beat
})
← PreviousLogger & Effect.logNext →Testing Basics