Effect-ts 预备课:理解 opencode 的编程基石

叙事角色:首席架构师 | 情绪曲线:好奇 → 原来如此 → 还能这样? 首席架构师 — 带读者站在作者视角,理解每个模块的设计意图与权衡 本文是 series-opencode-deconstruction 系列的第 3 篇

如果你打开 opencode 的源码,随便挑一个文件看,大概率会看到这行导入:

import { Effect, Layer, Context, Schema } from "effect"

effect 不是 Node.js 原生库,它是一个独立的 TypeScript 函数式编程框架——Effect-ts。在 opencode 项目中,911 个 TypeScript 文件导入了 effect,占了整个代码库的绝大部分。Effect-ts 不是 opencode 的一个"模块"或"工具库",它是整座大厦的钢筋混凝土。

但 Effect-ts 的学习曲线出了名的陡峭。它的概念栈从 Effect<A, E, R> 三元组到 Layer 依赖注入,再到 Scope / ScopedCache / ManagedRuntime,层层嵌套。如果你是第一次接触,看源码时可能连 yield* 是什么意思都要想半天。

本文不会教你 Effect-ts 的全部 API——那需要一本书。但读完这篇,你再看 opencode 的源码,至少能认出四个核心模式,知道它们各自解决什么问题。


【问题】没有 Effect-ts 会怎样

我们先看一个很常见的场景:查询用户信息,然后返回给调用方。

Promise 的三大短板

如果没有 Effect-ts,你会这样写:

async function findUser(id: string): Promise<User> {
  const db = await getDatabase()
  const rows = await db.query("SELECT * FROM users WHERE id = ?", [id])
  return rows[0]
}

简洁易懂。但加几个需求后,Promise 的三块短板就会暴露:

短板一:错误类型丢失。 db.query() 可能抛"连接超时"或"语法错误",但在 Promise 的世界里,catch 到的永远是 unknown。你只能用 instanceof 运行时检查——编译器不会帮你检查是否遗漏了某种错误类型。

短板二:取消不安全和资源泄漏。 用户按 Ctrl+C 时,Promise 不会自动释放数据库连接。你需要手动介入 AbortController,而 AbortController 是一个传染性极强的机制——所有中间函数都得透传 signal 参数。

短板三:依赖不可追踪。 findUser 依赖 getDatabase(),但这个依赖在函数签名中完全不可见。测试时必须 mock 全局模块或构造函数注入——没有任何编译期保障说你提供的 mock 是否正确。

下图左侧展示了 Promise 世界的这三个问题,右侧是 Effect-ts 的解决方案:

Promise 三大短板 vs Effect 方案

Effect 的三元组方案

Effect-ts 用一个类型同时表达了前文提到的三个维度:

Effect<User, DatabaseError | QueryError, Database>

这个三元组不是一个结构体——它是一个类型级别的契约。每个类型参数解决一块 Promise 的短板:

  • User(第一参数):成功时拿到的值——等价于 Promise<User> 里的 User
  • DatabaseError | QueryError(第二参数):可能失败的所有类型——编译器保证你覆盖了每一种错误路径
  • Database(第三参数):这个 Effect 依赖哪些 Service——Effect 运行时按这个签名去容器里找实现

你可以把它理解成"Promise + instanceof + 构造函数参数"三合一的版本,区别是这三者在类型层面绑定成了一个整体。改动任何一个维度,编译器都会告知你所有受影响的代码路径。

下图用流程图对比了同一个"查询用户"任务在两个世界中的完整路径:

Promise vs Effect 对比

yield* 是 Effect-ts 版的 await,但存在一个本质差异。

yield* 与 await 的本质差异

从视觉上看,yield*await 非常像:

// await: 暂停直到 Promise 完成
const user = await fetchUser(id)

// yield*: 暂停直到 Effect 完成
const user = yield* fetchUser(id)

yield*await 多做了一件关键的事:每次 yield* 都是一个 Effect 运行时调度点

当用户按 Ctrl+C 时,Effect 运行时会在这个调度点检查中断信号。如果检测到中断,运行时会自动跳过后续的 Effect 并执行所有注册的 Finalizer(释放连接、关闭文件等)。这个机制不需要你在业务代码中传递任何 signal 参数——它内嵌在运行时中。

更深一层的区别是 yield* 的语义精确性。在 Promise 中:

await db.query(sql)

这个 await 拿到的是 User 类型。但 db.query() 可能失败——不成功的路径被掩盖在 Promise 的类型系统之外了。而在 Effect 中:

const user = yield* db.query(sql)

这个 yield* 不是简单地从 Promise 中取值——它是从 Effect<User, DbError, Database> 中解构出 User,同时把错误路径和依赖路径暴露在类型签名中。

yield* 替代 await


【设计】三个核心抽象

Effect-ts 的概念多,但在 opencode 中真正高频使用的只有三个。掌握了它们,就能看懂 90% 的业务逻辑。

Schema.Class — 数据验证 + 品牌类型

Schema 的双重身份:编译时 + 运行时

opencode 中几乎所有面向外部的数据结构都用 Schema.Class 定义:

// packages/stats/core/src/config.ts
export class AppConfigValue extends Schema.Class<AppConfigValue>("AppConfigValue")({
  stage: Schema.NonEmptyString,
  publicUrl: Schema.NonEmptyString,
}) {}

这一行同时定义了两个东西:

  • <AppConfigValue>(尖括号):TypeScript 类型参数,自引用——extends Schema.Class<AppConfigValue> 意味着这个类的实例类型就是 AppConfigValue 本身。编译器用这个类型做检查。
  • ("AppConfigValue")(括号里的字符串):运行时的标识符。Schema 在验证失败时会在错误消息中用这个名字指明是哪个数据出错。

为什么要两个名字?因为 TypeScript 类型在编译后不存在了。运行时需要一个字符串来标识"验证出错的是哪个结构"。不常写 Schema 的人容易卡在这个 <T>("T") 双写模式上。

Schema.NonEmptyString 不是普通的 string——它在 Schema.String 的基础上加了一个品牌标记(Brand)。这个标记同时影响编译时和运行时:编译时阻止你把普通字符串赋值给这个字段,运行时在校验时额外检查 .trim().length > 0

品牌类型:编译器的栅栏

// packages/core/src/account.ts
export const ID = Schema.String.pipe(Schema.brand("AccountID"))
export type ID = Schema.Schema.Type<typeof ID>

export class Info extends Schema.Class<Info>("Account")({
  id: ID,   // 编译器阻止传普通 string
  email: Schema.String,
  url: Schema.String,
}) {}

Schema.String.pipe(Schema.brand("AccountID")) 创建了一个品牌类型。如果你试图这样写:

function createInfo(id: string) {
  return new Info({ id, email: "a@b.com", url: "" })
  //         ^^ ❌ Type 'string' is not assignable to type 'Brand<AccountID>'
}

编译器会报错,因为 string 不能赋给 Brand<"AccountID">。必须显式通过转换函数:

const safeID = Schema.decodeSync(ID)("usr_001")   // ✅ 返回 ID 类型
new Info({ id: safeID, email: "a@b.com", url: "" })  // ✅

Schema.decodeSync运行时验证输入确实是有效的非空字符串,加上品牌标记后返回。如果传入 "",它会抛出一个 ParseError

验证失败时

当数据来源不可靠(环境变量、API 请求体、数据库记录)时,使用 Schema.decodeUnknownSync

// 假设环境变量: STAGE="", PUBLIC_URL="invalid-url"  
const config = Schema.decodeUnknownSync(AppConfigValue)({
  stage: "",              // ❌ Schema.NonEmptyString: 验证失败
  publicUrl: "not-a-url", // ❌ Schema.NonEmptyString: 验证失败  
})
// ↳ 抛 ParseError: "AppConfigValue > stage: Expected a non-empty string"

抛出的 ParseError 是 Effect-ts 的内置错误类型,包含完整的错误路径链。在 opencode 中,这个错误通常通过 Effect.orDie 或者 catchTag 处理——前者表示"配置错误直接终止"(因为配置错了程序不该继续运行),后者表示"可以优雅降级"。

Schema + Config:从环境变量到类型的完整链路

把 Schema 和 Effect-ts 的 Config 模块组合起来,就得到 opencode 中最常见的配置读取模式:

// packages/stats/core/src/config.ts
const config = Config.all({
  stage: Config.succeed(Resource.App.stage),
  publicUrl: Config.string("PUBLIC_URL").pipe(
    Config.withDefault("http://localhost:3000")
  ),
}).pipe(Config.map(decodeAppConfigValue))
// 此时 config 的类型: Config<AppConfigValue>

每一步的类型变化:

  1. Config.string("PUBLIC_URL")Config<string>(声明从环境变量 PUBLIC_URL 读字符串)
  2. .pipe(Config.withDefault(...))Config<string>(如果环境变量不存在,用默认值)
  3. Config.all({ ... })Config<{ stage: string; publicUrl: string }>(合并两个配置)
  4. .pipe(Config.map(decodeAppConfigValue))Config<AppConfigValue>(把普通对象映射为 Schema 实例)

第 4 步的 Config.map(decodeAppConfigValue) 就是运行时验证的触发点。decodeAppConfigValue 会调用 Schema.decodeUnknownSync(AppConfigValue) 来校验最终值。

Schema.Class + Branded Type

Context.Service — 标签即身份

传统方案的问题:反射

传统的 DI 框架(NestJS、Spring)用装饰器标注依赖关系:

@Injectable()
class UserService {
  constructor(@Inject("DB") private db: Database) {}
}

这段代码在运行时依赖 reflect-metadata:Node.js 启动时 import "reflect-metadata" 会在全局 Reflect 对象上挂载 defineMetadata / getMetadata 方法。框架启动时扫描所有类,读取装饰器元数据,构建依赖图。这个过程涉及两次反射调用——一次读构造函数的参数类型元数据,一次根据 token 字符串查找对应的 provider。

副作用是:reflect-metadata 是一个全局副作用——一旦 import,它修改了 Reflect,所有模块共享这个被修改后的全局对象。而且只有等你运行到这行代码时,token 不匹配才会报错。

Effect-ts 的方案:标签

Effect-ts 不用装饰器,不用反射,用标签(Tag)

// packages/stats/core/src/database.ts
export class DatabaseConfig extends Context.Service<
  DatabaseConfig, DatabaseSettings
>()("@opencode/stats/DatabaseConfig") {
  static readonly layer = Layer.effect(DatabaseConfig, /* ... */)
}

export class DrizzleClient extends Context.Service<
  DrizzleClient, Drizzle
>()("@opencode/stats/DrizzleClient") {
  static readonly layer = Layer.effect(
    DrizzleClient,
    Effect.map(DatabaseConfig, makeDrizzle),
  )
}

三个参数拆解:<Self, Value>()(tagString)

这一行是三重重载的调用链,很多人第一次看到会懵。从左到右拆:

部分 含义 什么时候用到
Context.Service<Self, Value>() 泛型参数。Self = 这个类自己的类型。Value = 运行时实际拿到的值的类型 编译时:yield* 返回值类型推导
(tagString) () 的 IIFE——上一行返回一个函数,立刻调用。"@opencode/stats/DatabaseConfig" 是运行时查找的唯一键 运行时:容器中存的 key-value 对

具体到 DatabaseConfig 的例子:

  • Self = DatabaseConfigyield* DatabaseConfig 返回的类型就是这个类的实例。因为类本身也充当了标签。
  • Value = DatabaseSettings:运行时容器中实际存储的是一个 DatabaseSettings 值。yield* DatabaseConfig 拿到的运行时的值就是这个。
  • tagString = "@opencode/stats/DatabaseConfig":容器是一个 Map<string, any> 结构。yield* 实际上做的是 container.get("@opencode/stats/DatabaseConfig")

所以当你在业务代码中写 const config = yield* DatabaseConfig 时,编译器推导 config: DatabaseSettings,运行时从容器中根据 "@opencode/stats/DatabaseConfig" 字符串查找并返回之前通过 Layer.effect 绑定的值。

Context.Service vs Context.Tag

Effect-ts 实际上提供两个级别的标签 API:

  • Context.Tag<Value>()(tagString):低阶 API。只包装值类型,不提供 layer 等便捷方法。在 opencode 中仍然出现(尤其 stats 包中),但较少。
  • Context.Service<Self, Value>()(tagString):高阶 API。继承 Context.Tag 并额外提供了 Service.of() 工厂方法和与 Layer.effect 的便捷配合。opencode 统一用这个。

本质上,Context.Service<Self, Value> 可以理解为一个「带自引用类型的 Context.Tag」。开头的双名 <AppConfigValue>("AppConfigValue") 模式同理。

没有反射的实际收益

Effect-ts 不依赖 reflect-metadata 带来的不是"优雅",而是三个具体的工程收益:

  1. 加载顺序无关:装饰器方案中 provider 的声明顺序影响依赖图构建。Effect-ts 的 Layer 组合是纯函数,顺序不影响正确性。
  2. 没有全局副作用reflect-metadataimport "reflect-metadata" 是一个全局修改。Effect-ts 的 Context 是纯数据结构,不修改任何全局状态。
  3. Tree-shaking 可行:装饰器的 @Injectable() 在编译时产生不可静态分析的元数据写入调用。Effect-ts 的 Context.Service 只是一个类继承——没有被引用的 Layer 会被 tsc 标记为 unused。

下图展示了 yield* DatabaseConfig 这句代码在 Effect 运行时中的完整查找流程——从标签到实现,再到值:

Context.Service 运行时查找流程

Context.Service + Layer.effect 代码定义

Layer — 可组合的 DI

Layer 本身的泛型:也是一个三元组

Layer 自己也有三个类型参数,和 Effect<A, E, R> 结构一致:

Layer.Layer<A, E, R>
  A = 产出的服务类型(这个 Layer 提供了什么)
  E = 初始化过程中可能发生的错误
  R = 这个 Layer 依赖哪些其他 Service

举例:

// 这个 Layer 产出 DrizzleClient,不依赖其他服务(never)
Layer.Layer<DrizzleClient, never, never>

// 这个 Layer 产出 DatabaseConfig,可能失败,不依赖其他服务  
Layer.Layer<DatabaseConfig, never, never>

// 这个 Layer 产出 DrizzleClient,依赖 DatabaseConfig 先就绪
Layer.Layer<DrizzleClient, never, DatabaseConfig>

第三个类型参数 R 是 Layer 组合的核心机制——当 R 不为 never 时,这个 Layer 在被创建之前,Effect 运行时必须先解析 R 中列出的所有依赖。这个解析过程是递归的、编译期可见的。

三种创建方式的选型

方式 签名 适用场景 执行时机 opencode 案例
Layer.succeed(Tag, value) 同步纯值 没有初始化逻辑,直接给一个值 ManagedRuntime.make 时立即执行 很少——因为即使"纯值"也可能来自配置
Layer.sync(Tag, factory) 同步工厂 () => V 有轻量初始化,不涉及 Effect 被使用时延迟执行 Logger 等没有 I/O 的服务
Layer.effect(Tag, effect) Effect 工厂 Effect<V, E, R> 需要异步/可能有依赖的初始化 被使用时执行,R 中的依赖先于它就绪 DatabaseConfigDrizzleClientAthena

opencode 中绝大多数 Layer 用 Layer.effect,因为初始化通常涉及读取配置或建立连接。Layer.sync 用于纯计算型服务。Layer.succeed 很少单独出现——它被 Layer.mergeAll 等组合器内部使用。

依赖链可视化:provide 的数据流

Layer 有三个类型参数:Layer.Layer<产出(A), 错误(E), 依赖(R)>Layer.provide 的作用是:用一个 Layer 的产出,去填另一个 Layer 的依赖,填完之后被填的那个 Layer 就不再需要这个依赖了。

DrizzleClient 的例子拆开看:

// DatabaseConfig.layer 的签名
Layer.Layer<DatabaseSettings, never, never>
//           ↑ 产出 DatabaseSettings   ↑ 不需要任何依赖

// DrizzleClient.layer 的签名
Layer.Layer<DrizzleClient, never, DatabaseConfig>
//           ↑ 产出 DrizzleClient      ↑ 依赖 DatabaseConfig

DrizzleClient.layer 的依赖 R = DatabaseConfig——它需要一个 DatabaseConfig 类型的 Service 在它之前初始化。DatabaseConfig.layer 的产出 A = DatabaseSettings——它的产出正好匹配 DrizzleClient 需要的依赖。

把它们连起来:

const fullLayer = DrizzleClient.layer.pipe(
  Layer.provide(DatabaseConfig.layer)
)
// fullLayer 的签名:
// Layer.Layer<DrizzleClient, never, never>
//                 ↑ 产出不变        ↑ 依赖变 never —— DatabaseConfig 已被满足

Layer.provide 的效果是:被 provide 的 Layer 从依赖(R)中移除,它的产出(A)成为供给。 如果 DrizzleClient.layer 除了 DatabaseConfig 还依赖其他 Service(比如 Logger),provide(DatabaseConfig.layer) 只会移除 DatabaseConfigLogger 仍留在 R 中。

当最终的 Layer 的 R = never 时,ManagedRuntime.make 才能安全地执行它——因为所有依赖都已注册完毕。

下图展示了 provide 前后的类型变化:

Layer.provide 数据流

mergeAll vs provideMerge

这两个是 opencode 最常用的组合方式,写法非常像,语义不同:

Layer.mergeAll(A, B, C)——A、B、C 是三个并列的、互不依赖的 Service。合并成一个同时产出 A+B+C 的大 Layer。

export const AppLayer = Layer.mergeAll(
  Npm.defaultLayer,        // 产出 Npm
  FSUtil.defaultLayer,     // 产出 FSUtil  
  Database.defaultLayer,   // 产出 Database
  Auth.defaultLayer,       // 产出 Auth
  // ... 40+ services
).pipe(
  Layer.provideMerge(InstanceLayer.layer),  // 追加 InstanceLayer 到产出集合
  Layer.provideMerge(Ripgrep.defaultLayer),
)

Layer.provideMerge(B)——B 的产出被注入到 A 的 R(依赖)中,同时 B 也成为合并后 Layer 的产出之一。

AppLayer 的构建过程是:先用 mergeAll 把 40+ 并列 Service 合并,再用 provideMerge 把那些依赖了其他 Service 的 Layer 接入。InstanceLayer.layer 依赖 InstanceBootstrap,所以不能直接 mergeAll——必须先确保 InstanceBootstrap 就绪再 provide。

下图展示了这三种组合模式在 opencode 中的具体写法:

Layer 的三种组合模式

三个抽象的协作关系

把前面三节串起来,工作流程是这样的:

Schema.Class 定义数据格式
     ↓  Context.Service 为数据贴上服务标签
     ↓  Layer 把标签绑定到具体实现
     ↓  ManagedRuntime 把 Layer 跑起来

底层的数据(Schema.Class)通过标签(Context.Service)被暴露给上层;Layer 将标签映射到实现;最后 ManagedRuntime.make(AppLayer) 将所有 Layer 组合成一个可执行的运行时。从外部看,你只需要 yield* ServiceTag,剩下的全部由运行时自动完成。

Effect-ts 三大核心抽象


【源码】Effect.fn 命名生成器模式

来看一段在 opencode 中出现了 1,137 次的模式——Effect.fn 命名生成器。

命名追踪:Effect.fn

// packages/stats/core/src/athena.ts
const poll = Effect.fn("Athena.poll")(function* (
  client: AwsAthenaClient,
  queryExecutionId: string,
  attempt = 0,
) {
  if (attempt > 0) yield* Effect.sleep("2 seconds")

  const result = yield* Effect.tryPromise({
    try: () => client.send(new GetQueryExecutionCommand({ queryExecutionId })),
    catch: (cause) => new AthenaQueryError({ message: "Failed to poll", queryExecutionId, cause }),
  })

  if (result.QueryExecution?.Status?.State === "SUCCEEDED") return
  if (result.QueryExecution?.Status?.State === "FAILED") {
    return yield* new AthenaQueryError({ message: `Query failed: ...`, queryExecutionId })
  }
  if (attempt >= 60) {
    return yield* new AthenaQueryTimeoutError({ message: `Query did not complete`, queryExecutionId })
  }

  return yield* poll(client, queryExecutionId, attempt + 1)
})

Effect.fn("Athena.poll") 给这个 Effect 一个可读名称。在追踪和日志中,你可以看到 Athena.poll 出现在调用链中——这在调试 40+ Service 的复杂交互时非常有用。

yield* Effect.sleep("2 seconds")await setTimeout() 的区别前文已经讨论过——关键点在于可取消。如果用户在轮询过程中按 Ctrl+C,这个 sleep 不会"泄漏"到超时触发。

yield* Effect.tryPromise(...) 把第三方 Promise 桥接到 Effect 世界。catch 返回 typed error(AthenaQueryError),失联路径被类型系统捕获。

yield* new AthenaQueryError(...):在 Effect 中,throwyield* new ErrorType(...) 取代。这不是 throw——这是 Effect.fail 的语法糖。调用方可以在 Effect<Data, Error> 的第二个类型参数里看到所有可能的错误。

递归yield* poll(client, queryExecutionId, attempt + 1) 是递归 Effect。每次递归调用都经过 Effect 运行时的调度,运行时可以在递归边界做中断检测。

Effect.fn 轮询源码


【权衡】为什么是 Effect-ts

任何技术选型都有代价。下面是五个维度的系统性对比:

五维对比

对比维度 Effect-ts NestJS 裸 async/await
运行时依赖 0 反射,纯静态分析 反射 + 装饰器元数据 无依赖,手动管理
错误处理 Effect 类型安全 ExceptionFilter 运行时捕获 throw/try-catch 无类型约束
取消安全 Scope + Finalizer 编译期保障 手动 + 中间件易遗漏 AbortController 传染性
DI 组合 Layer.mergeAll 一行合并 40+ Service @Module 装饰器声明 手动传参无容器
学习曲线 陡峭,函数式思维 中等,装饰器熟悉 低,标准语法

选 Effect-ts 的核心原因不是"函数式编程更优雅"——而是 0 运行时反射 + 编译期 DI 约束。

NestJS 的装饰器方案在运行时依赖 reflect-metadata——通过反射读取装饰器元数据来构建依赖图。这意味着:

  1. 反射调用在运行时发生——如果你写错了 @Inject() 的 token,只有运行到那行才知道
  2. reflect-metadata 是一个全局副作用——一旦 import,它修改了全局的 Reflect 对象
  3. Tree-shaking 困难——装饰器在编译时不可静态分析,打包工具无法确定哪些依赖没被使用

Effect-ts 的类型层面方案把这些问题消灭在编译期:类型错误编译时不通过,不产生运行时元数据开销,没被引用的 Layer 会被 tsc 标记为 unused import。

下图从五个维度展示了三个方案的具体差异:

方案权衡对比

不过,Effect-ts 的陡峭学习曲线是一个真实的成本。以下场景中这个成本可能超过收益:脚本或一次性任务、页面渲染为主的纯前端应用、团队无人熟悉函数式编程(新人需要 2-4 周才能产出高效代码)。opencode 的情况刚好相反——40+ 个 Service 互相依赖、需要取消安全、需要类型安全的错误处理——Effect-ts 的优势完全发挥。


【锚点】下次你写"需要注入一个 Service"时

与其记 API,不如记决策框架。

四步框架 + 三个自问题

当你需要一个 Service 时:

1. Schema.Class — 定义数据格式
   → 编译时类型 + 运行时验证。写一次,两处受益。

2. Context.Service — 声明标签
   → 标签即身份,无 @Inject,无 reflect-metadata。

3. Layer.effect / Layer.succeed — 绑定实现
   → 一个 Layer 完成 Tag → Value 的映射。

4. Layer.mergeAll + ManagedRuntime.make — 组合运行
   → 几十个 Service 一行合并,零反射。

每次设计 DI 方案时,再用三个问题检验:

  • "数据和行为能拆开吗?" → Schema.Class 定义数据,Context.Service 定义行为接口
  • "依赖需要在编译期保证吗?" → Effect-ts 的答案是"是的"——yield* 编译不过是忘提供 Service 的信号
  • "Scope 谁管理?" → 资源型 Service 用 Layer.effect + Effect.addFinalizerScopedCache 托管

决策框架与自问题

学会了这四个模式和三个问题,你再回头看 opencode 的源码——不管是 instance-state.ts 里的 ScopedCache,还是 athena.ts 里的 Effect.fn,还是 app-runtime.ts 里那个 48 个 Service 的 Layer.mergeAll——应该都不会觉得陌生了。


📖 全文带完整截图和源码引用 🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操