Effect.fn 为什么不能 .pipe,后处理参数怎么用?

你给 Effect.fn 定义的函数写 .pipe(Effect.map(...)),TypeScript 会直接报错:Property 'pipe' does not exist on type '() => Effect<number>'。官方示例里甚至专门留了一句注释,告诉你别这么写。

Effect.fn(...)(body) 返回的是一个函数,不是 Effect,函数上没有 pipe 方法;要给它加后续处理,把处理函数写在函数体后面,作为后处理参数,它们会收到上一步的结果和原始调用参数。

这个设计不是限制,而是为了让后处理跑在 span 里面、还能读到调用参数。如果你不确定 Effect.fn 和 Effect.fnUntraced 有什么区别,先看 Effect.fn 和 fnUntraced 怎么选?;基础的 Effect.gen 用法见为什么我用 Effect。下面按顺序讲清楚报错的原因、后处理参数怎么写、它为什么比手写包装好。

为什么对 Effect.fn 用 .pipe 会报错?

import { Effect } from "effect"

const f = Effect.fn("x")(function*() {
  return 1
})

// @ts-expect-error Effect.fn 返回函数,不是 Effect,没有 .pipe
export const g = f.pipe(Effect.map((n) => n + 1))

这个块在 strict 下能编译,说明编译器确实报了错(Property 'pipe' does not exist on type '() => Effect<number>');运行时 typeof f.pipe 也是 undefined。

.pipe 是 Effect 值上的方法,而 f 是「接收参数、返回 Effect 的函数」,要先调用 f() 才得到 Effect。

后处理参数怎么写?

函数体之后可以继续传入多个函数,每个接收「上一步的结果」和「原始调用参数」,行为像 pipe,但能读到参数。同一个例子里还用了 Effect.fn.Return<A, E, R>,它是官方给生成器函数体的返回类型标注,参数和 Effect<A, E, R> 一致,让错误类型显式落在签名上:

import { Data, Effect } from "effect"

class NotFound extends Data.TaggedError("NotFound")<{ readonly id: number }> {}

export const load = Effect.fn("User.load", { attributes: { "db.system": "sqlite" }, kind: "client" })(
  function*(id: number): Effect.fn.Return<string, NotFound> {
    yield* Effect.annotateCurrentSpan("user.id", id)
    if (id < 0) return yield* new NotFound({ id })
    return `user-${id}`
  },
  (eff, id) =>
    eff.pipe(
      Effect.catchTag("NotFound", (e) => Effect.succeed(`missing-${e.id}`)),
      Effect.tap(() => Effect.annotateCurrentSpan("post", `handled ${id}`)),
      Effect.flatMap((v) =>
        Effect.currentSpan.pipe(
          Effect.map((s) => `${v} ${JSON.stringify(Object.fromEntries(s.attributes))} kind=${s.kind}`)
        )
      )
    )
)

console.log(await Effect.runPromise(load(1)))
console.log(await Effect.runPromise(load(-2)))

输出:

user-1 {"db.system":"sqlite","user.id":1,"post":"handled 1"} kind=client
missing--2 {"db.system":"sqlite","user.id":-2,"post":"handled -2"} kind=client

第一,NotFound 在后处理里被 catchTag 消化,返回类型里就没有这个错误了。第二,后处理函数里 (eff, id) 的 id 就是调用 load 时传入的参数。

另外,span 的 attributes 选项是静态对象,随参数变化的属性要像这里的 Effect.annotateCurrentSpan("user.id", id) 一样在函数体里追加。

后处理是在 span 里面还是外面?

在 span 内部。上面的输出能证明:我在后处理里读到了 User.load 这个 span,并往里追加了 post 属性,最终 attributes 里同时有 db.system、user.id 和 post。

所以重试、超时、日志注解这类横切逻辑放进后处理参数,会被同一个 span 覆盖,不需要再单独包一层 Effect.withSpan。

手写一层包装不行吗?

即使你写成 (...args) => f(...args).pipe(...) 绕开 .pipe 的限制,也有代价:额外的包装在 span 外面,丢掉了「后处理在 span 内」的语义,调用方还要自己转发参数。

后处理参数把这两件事一次解决,所以当你想给一个函数批量加重试、超时、错误转换时,直接把它们写进 Effect.fn 的后面。重试和超时谁包着谁,见 Effect 里 timeout 和 retry 谁包着谁?。

版本是 effect 4.0.5,本文的编译结果和两段输出都在这个版本上实际跑过。「官方示例注释不要对 Effect.fn 用 .pipe」和 Effect.fn.Return 的用途说明沿用官方文档的表述,没有单独核对原文;重试与超时放进后处理参数的做法是我从「后处理在 span 内执行」推出来的,没有单独写代码跑过。