Chapter 04

陷阱:10 个真实失败模式

前三章建立了概念、机制、实操。这一章是反面教材——新手最常撞上的 10 个失败模式,按命中时间排序。每个都给症状、根因(回到 02 章的机制),和正反代码对照。看完你能在写错时认出"这是哪一类问题"。

本章你将建立的 schema

  • 把模糊的"它不工作"翻译成具体失败模式 + 根因机制
  • 四类陷阱:惰性、错误通道、依赖注入、并发——各自的典型症状
  • 诚实的边界:什么时候不该用 Effect

4.0陷阱地图

10 个陷阱归四类,每一类都能追溯到 02 章的某个机制。撞上某个症状时,先定位它属于哪一类,再回到对应机制看根因——比死记 10 条快。

惰性陷阱 P1 · P2 · P3 错误通道 P4 · P5 依赖注入 P6 · P7 并发陷阱 P8 · P9 · P10 根因 §2.1 惰性 根因 §2.2 失败/缺陷 根因 §2.3 Layer 根因 §2.4 / 2.5 fiber
图 4.0每类陷阱都挂在一个机制下。注意:调试 Effect 的高效路径不是背陷阱,而是"症状 → 归类 → 回到那个机制看根因"——这也是为什么 02 章要先于本章。

4.1惰性陷阱(P1–P3)

P1 · 定义了 effect,却什么都没发生

症状:写了 Effect.log("hi") 或一段抓取逻辑,运行程序,控制台一片空白,副作用没发生。
根因:effect 是惰性的值(§2.1),定义不等于执行。没有 run*,它永远不跑。

p1.tsTypeScript
// ❌ 什么都不会打印 —— effect 只是个值
const logHi = Effect.log("hi")

// ✅ 交给 runtime 才执行
Effect.runSync(Effect.log("hi"))

如何避免:记住"effect = 值"。程序里应当只有极少数 run*(理想是最外层一个);其余全是返回 effect、由上层组合的纯函数。

P2 · 在 Effect.gen 里混用 async/await

症状:把 fetch 直接 await 进生成器,类型乱成一团,或运行行为脱离 Effect 的控制(错误不进 E、不可中断)。
根因:gen 用 yield* 组合 effect(§2.1 + 01.5);await 会即时执行并把错误变回未类型化的抛出,绕过整套机制。

p2.tsTypeScript
// ❌ async generator + await —— 脱离 Effect 世界
Effect.gen(async function* () {
  const r = await fetch(url)        // 错:await 不该出现在这里
})

// ✅ 用 tryPromise 把 Promise 包成 effect,再 yield*
Effect.gen(function* () {
  const r = yield* Effect.tryPromise({
    try: () => fetch(url),
    catch: (e) => new HttpError({ status: 0 }),
  })
})

如何避免:gen 的函数永远是普通 function*,不是 async function*;遇到 Promise 一律 tryPromise 包成 effect 再 yield*。

P3 · 看到 yield* _(...) 适配器

症状:照着某篇博客写 Effect.gen(function* (_) { yield* _(eff) }),编译报错或行为怪异。
根因:那个 _ 适配器是旧语法,3.0(2024-04)起已移除(§现状速览)。

p3.tsTypeScript
// ❌ 旧版适配器(已移除)
Effect.gen(function* (_) {
  const x = yield* _(Effect.succeed(1))
})

// ✅ 直接 yield*(需 TS 5.5+)
Effect.gen(function* () {
  const x = yield* Effect.succeed(1)
})

如何避免:看到 _ 适配器就知道资料早于 2024-04,整篇都要按现版本核对。

4.2错误通道陷阱(P4–P5)

P4 · throw 进了 Effect.sync,catchAll 抓不到

症状:明明写了 catchAll,程序还是以未处理错误崩溃。
根因:Effect.sync 假定不抛;真抛出的异常变成缺陷(§2.2),不在 E 通道,而 catchAll 只管 E 通道。

p4.tsTypeScript
// ❌ JSON.parse 抛出 → 缺陷 → catchAll 漏掉
const bad = Effect.sync(() => JSON.parse(raw)).pipe(
  Effect.catchAll(() => Effect.succeed(null)),
)

// ✅ 用 try,把异常转进 E 通道,再按标签恢复
const good = Effect.try({
  try: () => JSON.parse(raw),
  catch: () => new ParseError({ raw }),
}).pipe(Effect.catchTag("ParseError", () => Effect.succeed(null)))

如何避免:会抛的同步代码一律 Effect.try(不是 sync);只有确信不抛才用 sync。要兜底缺陷用 catchAllCause。

P5 · catchAll 一把抓,悄悄吞错

症状:线上出问题但日志干净,错误被静默成了 fallback。
根因:catchAll 把整个 E 通道 collapse 成 never(§2.2 的减法),包括你没预料到的错误——它们被一并吞掉。

p5.tsTypeScript
// ❌ 吞掉一切,真问题被掩盖
risky.pipe(Effect.catchAll(() => Effect.succeed(fallback)))

// ✅ 只处理你认识的标签,其余继续浮现在类型里
risky.pipe(Effect.catchTag("HttpError", recover))

如何避免:优先 catchTag/catchTags 精确处理;catchAll 留给真正的最后兜底,且要记日志。

4.3依赖注入陷阱(P6–P7)

P6 · runPromise 处类型报错:Service 不能赋给 never

症状:Type 'HttpClient' is not assignable to type 'never',新手以为是 bug。
根因:这是特性(§2.3)。R 通道还挂着没提供的服务,编译器拒绝运行一个依赖未满足的 effect。

p6.tsTypeScript
// ❌ R 还挂着 HttpClient
Effect.runPromise(fetchWeather("x"))

// ✅ 先 provide,把 R 消成 never
Effect.runPromise(
  fetchWeather("x").pipe(Effect.provide(HttpClientLive)),
)

如何避免:把报错读成"你还差一个依赖没接"。照着 R 通道列出的服务,逐个 provide 对应 Layer。

P7 · 同一个 layer 提供两次,得到两个实例

症状:"单例"服务却有两份状态(两个连接池、两份缓存)。
根因:Layer 按引用相等记忆化(§2.3)。把工厂 makeLayer() 调两次产生两个不同引用 = 两次构建。

p7.tsTypeScript
// ❌ 每次调用工厂都是新引用 → 两个连接池
const makeDb = () => Layer.effect(Db, openPool)
a.pipe(Effect.provide(makeDb()))   // 实例 1
b.pipe(Effect.provide(makeDb()))   // 实例 2(不同引用)

// ✅ 建一次、共享同一引用 → 记忆化为同一实例
const DbLive = Layer.effect(Db, openPool)
a.pipe(Effect.provide(DbLive))
b.pipe(Effect.provide(DbLive))

如何避免:Layer 定义成模块级 const,到处共享同一个引用;只在确实想要新实例时用 Layer.fresh。

4.4并发陷阱(P8–P10)

P8 · Effect.all 默认顺序执行,比预期慢

症状:以为像 Promise.all 那样并行,结果 5 个请求串行跑了 5 倍时间。
根因:Effect.all 默认 sequential(§2.4),这是有意的安全默认——并发度要你显式声明。

p8.tsTypeScript
// ❌ 默认串行
Effect.all([a, b, c])

// ✅ 显式声明并发度
Effect.all([a, b, c], { concurrency: "unbounded" }) // 或具体数字,如 5

如何避免:写 Effect.all 时永远想一下并发度。"unbounded" 会一拥而上打爆下游——对数据库/外部 API 用具体数字限流。

P9 · 失败或中断时资源泄漏

症状:偶发的连接耗尽、文件句柄泄漏,通常在出错或超时路径上。
根因:手动 open/close,中间失败或 fiber 被中断(§2.5)时 close 没跑。

p9.tsTypeScript
// ❌ useConn 失败或被中断 → closeConn 漏掉
Effect.gen(function* () {
  const conn = yield* openConn
  yield* useConn(conn)     // 这里炸了
  yield* closeConn(conn)   // 不会执行 → 泄漏
})

// ✅ acquireRelease:release 注册为 finalizer,成功/失败/中断都跑
Effect.acquireRelease(openConn, (conn) => closeConn(conn)).pipe(
  Effect.flatMap(useConn),
  Effect.scoped,
)

如何避免:任何"获取-使用-释放"的资源都用 acquireRelease + scoped,永不手动 close。

P10 · Effect.all 默认 fail-fast,一个失败炸全批

症状:批量任务里一个失败,整批中止,其余结果全丢。
根因:Effect.all 默认 fail-fast——任一失败就短路并中断其余(§2.2 + §2.5)。想隔离失败得把每项先变成值。

p10.tsTypeScript
// ❌ 任一失败 → 整体失败 + 中断其余
Effect.all(tasks)

// ✅ 每项先 exit 成值,失败被隔离,整批永远成功,逐项查 Exit
Effect.all(tasks.map((t) => Effect.exit(t)))

如何避免:先问"一个失败该不该影响其他"。该隔离就 Effect.exit 每项(如 03 章调度器);该全有或全无才用裸 Effect.all。

4.5何时不要用 Effect(诚实回答)

Effect 不是免费的。它的回报在复杂度上限高的系统里才显著,小场景里学习曲线不划算。

表 4.1 · 用 / 不用的判断
场景建议理由
一次性脚本、简单胶水别用Promise + try/catch 足够,引入 Effect 不划算
团队无人懂、且不打算投入学习别用全员看不懂的代码是负债,不是资产
想逐函数渐进引入慎用Effect 偏"全有或全无";只要 Result 用 neverthrow 更轻
复杂并发编排 / 强错误保证后端 / 长生命周期 Agent值得类型化错误、结构化并发、可测试性的回报随复杂度放大

§本章 self-check

先合上教程作答,再展开对照。

  1. P1(什么都没发生)和 P8(比预期慢)表面无关,但都源自同一个"反直觉默认"。各是什么默认?
  2. P4 和 P5 都和 catch 有关,但根因相反。分别是什么?
  3. P7 的"两个实例",要改成单例,最小改动是什么?
答案(先做完再展开)
  1. P1:effect 惰性,定义不执行(要 run)。P8:Effect.all 默认串行(要显式 concurrency)。两者都是"Effect 的默认和 Promise 直觉相反"。
  2. P4:异常变成缺陷没进 E 通道,catchAll 抓不到(该用 try)。P5:catchAll 抓得太多,把没料到的错误也吞了(该用 catchTag)。一个漏抓、一个滥抓。
  3. 把 makeDb() 工厂调用换成一个模块级 const DbLive = ...,两处都 provide 这同一个引用——记忆化即生效。
进阶挑战 · 刚好够不着

造一个会泄漏的最小例子,再修好

写一段代码:acquire 打开资源后,在 use 阶段被一个 Effect.timeout 中断。先用手动 open/close 复现"中断时不释放",再用 acquireRelease 修好,用日志证明两种写法下 release 是否运行。

提示(卡住再展开)

让 use 阶段 Effect.sleep("5 seconds"),外面套 Effect.timeout("1 second")。手动版在 sleep 处被中断,closeConn 那行根本到不了。acquireRelease 版把 close 注册成 finalizer,中断时 runtime 仍会跑它(回看 §2.5 的 interrupt-finalizer.ts)。用 Console.log 在 release 里打印来对照。