Chapter 01

概念:Effect 是一个值

入口页给了概念地图——这一章把地图上的每个节点变成你能读、能写的词汇。读完,你能看着任意一段 Effect 代码的类型签名说出:它产出什么、会怎么失败、依赖谁。

本章你将建立的 schema

  • "Effect 是惰性的值,不是正在执行的操作"——和 Promise 的根本区别
  • Effect<A, E, R> 三个类型参数各自的含义
  • 创建(succeed/fail/sync/tryPromise)、运行(run*)、组合(pipe/gen)三组基本动作
  • 类型化错误、服务+Layer、Fiber 的第一印象(原理留到 02,落地留到 03)

1.1Effect 是值,不是执行

一个 Effect 是对"要做什么"的惰性描述——一张配方,不是一盘已经炒好的菜。

为什么需要它

Promise 一构造就开始跑(hot):副作用立刻发生,你拿到的是"已经在飞的结果"。这让重试、超时、取消、依赖注入都很难加——操作已经启动了。Effect 把"描述"和"执行"分开:先得到一个可以被传递、组合、包装的值,到边界处才交给 runtime 执行。重试就是"把这个值再跑一次",超时就是"给这个值套一层"。

类比 · 带边界声明

Effect 像一份函数定义:写出来不等于调用它。但类比到此为止——普通函数调用一次就执行;Effect 值即使被 yield* 组合进更大的程序,整体仍然是个没运行的值,直到 run*。

Promise(热) Effect(冷 / 惰性) new Promise(fn) 构造即执行 fn 立即运行 结果已经在飞 Effect.sync(fn) = 一个值 Effect 值 什么都还没运行 run* fn 此刻才运行
图 1.1同样写下一段副作用,Promise 已经跑完,Effect 还停在值的状态。注意:右边多出来的那一步 run* 就是 Effect 全部威力的来源——在它之前,这个值可以被重试、超时、注入、测试。
01-effect-is-a-value.tsTypeScript
import { Effect } from "effect"

// Promise:构造的那一刻,函数体就运行了
const p = new Promise<number>((resolve) => {
  console.log("A: Promise 体立刻执行")
  resolve(42)
})

// Effect:只是一个描述。下面这行不会打印任何东西。
const program = Effect.sync(() => {
  console.log("B: 只有 run 时才执行")
  return 42
})

console.log("--- 到这里,只有 A 打印过,B 还没有 ---")

// 现在才执行 program
const result = Effect.runSync(program) // 此刻打印 "B: ..."
console.log("result =", result)        // 42

运行:npx tsx 01-effect-is-a-value.ts 预期输出顺序:

输出text
A: Promise 体立刻执行
--- 到这里,只有 A 打印过,B 还没有 ---
B: 只有 run 时才执行
result = 42

逐行解读(代码 → 概念)

  • new Promise(...):构造即执行,所以 "A" 在最顶上就打印了——这是"热"。
  • Effect.sync(() => {...}):只是把副作用包进一个值。"B" 没打印,因为还没 run——这是"冷"。这就是定义里"惰性描述"的含义。
  • Effect.runSync(program):把值交给 runtime,"B" 此刻才打印。执行被推迟到了边界。
想一想

如果把 Effect.runSync(program) 写两遍,"B" 会打印几次?

展开答案(先停 10 秒)

两次。program 是个值,每次 run 都重新执行它描述的副作用。这正是为什么"重试 = 再 run 一次"在 Effect 里如此自然——而对一个已经 settle 的 Promise,你无法让它再跑一遍。

与下一个概念的关系:既然 Effect 是个值,它的类型就能携带信息。下一节看这个类型携带了哪三件事。

1.2三通道:Effect<A, E, R>

每个 Effect 的类型有三个参数:A=成功值,E=失败类型,R=需要的依赖。

为什么需要它

async function fetchUser(): Promise<User> 只告诉你成功类型 User。它会抛什么错?需要哪些外部依赖?签名里看不到——你得读实现、读文档、或者线上炸了才知道。Effect 把这三件事全提到类型层面:Effect<User, HttpError, Database> 一眼说清"成功给 User、以 HttpError 失败、需要 Database 才能跑"。

Effect<A, E, R> A · 成功值 成功时返回什么 succeed(x) 决定 never = 永不返回 E · 错误 以什么类型失败 fail(x) 决定 never = 不会失败 R · 依赖 需要哪些服务 yield* Tag 引入 never = 无依赖
图 1.2三个类型参数,三套独立机制。注意:never 在三个位置都表示"空"——E=never 是"不会失败",R=never 是"无依赖、可以直接 run"。记住这个,02 章看错误被消除时就不会困惑。
02-three-channels.tsTypeScript
import { Effect } from "effect"

const a = Effect.succeed(42)
//    a: Effect<number, never, never>   成功 number;不会失败;无依赖

const b = Effect.fail(new Error("boom"))
//    b: Effect<never, Error, never>    永不成功;以 Error 失败;无依赖

// 只有 E=never 且 R=never 的 effect,才能用 runSync 直接同步求值
const value: number = Effect.runSync(a) // 42
  • Effect.succeed(42) 的类型是 Effect<number, never, never>:A 被填成 number,E 和 R 都是 never(不会失败、无依赖)。
  • 反过来,Effect.fail(...) 把 A 填成 never、E 填成 Error:它永远不产出成功值。
  • 能直接 runSync 的前提是 E 和 R 都已经是 never——没有未处理的错误、没有未提供的依赖。这一点 1.7 会再撞上。

与下一个概念的关系:知道了类型三参数,下面看怎么造出各种 Effect 值。

1.3创建 effect

用 succeed/fail/sync/promise/tryPromise 把"普通值、副作用、Promise"包成 Effect。

为什么需要它

真实代码的边缘全是非 Effect 的东西:常量、会抛异常的同步调用、返回 Promise 的 fetch。这组构造器是"入口闸门"——把外部世界的值和副作用,搬进 Effect 的类型系统里,从此错误和依赖才能被追踪。

03-creating.tsTypeScript
import { Effect } from "effect"

Effect.succeed(42)                 // Effect<number>        已有的值
Effect.fail(new Error("boom"))     // Effect<never, Error>  已知的失败

Effect.sync(() => Date.now())      // 包裹"不会抛"的同步副作用
Effect.try(() => JSON.parse(raw))  // 包裹"可能抛"的同步调用,抛出进入 E 通道

// 包裹会 reject 的 Promise;catch 把 reject 转成类型化错误
Effect.tryPromise({
  try: () => fetch("https://api.example/ping"),
  catch: (cause) => new Error(`network: ${String(cause)}`),
})
陷阱(04 章详述)

Effect.sync 用于不会抛的副作用。如果包裹的函数其实会抛异常,用 Effect.try——否则异常会变成"缺陷"(defect)绕过你的类型化错误处理。这个区别是 02 和 04 章的重点。

与下一个概念的关系:造好了值,怎么让它真正跑起来?

1.4运行 effect:唯一的边界

run* 是把惰性 Effect 交给 runtime 执行的唯一出口,通常只在程序最外层调用一次。

为什么需要它

组合、map、错误处理、依赖注入全发生在"Effect 世界"里,都不执行。run* 是这个世界与真实世界之间唯一的门。把门开在最外层(main、HTTP handler、CLI 入口),内部全是可组合、可测试的纯描述——这是 Effect 程序的标准形状。

Effect 世界 · 惰性可组合 succeed map flatMap runPromise 真实世界 副作用真正发生
图 1.3虚线框内全是没执行的值,组合再多也不触发副作用。注意:只有一条实线箭头穿过边界——把 run* 收敛到最外层,内部就全部可测试。
04-running.tsTypeScript
import { Effect } from "effect"

const program = Effect.succeed(21).pipe(Effect.map((n) => n * 2))

Effect.runSync(program)            // 42      同步求值;effect 异步或会失败则抛
await Effect.runPromise(program)   // 42      → Promise<A>,失败则 reject
const fiber = Effect.runFork(program)        // 在后台 fiber 跑,返回 Fiber
const exit = await Effect.runPromiseExit(program)
//    exit: Exit<number, never>    成功/失败都不 reject,结果装在 Exit 里
  • runSync:要求 effect 完全同步、且 E=never。否则抛错——它是"保证能立刻、安全求值"的断言。
  • runPromise:异步程序的常用出口,失败会让 Promise reject。
  • runFork / runPromiseExit:前者拿到一个可中断的 Fiber(1.8);后者把成功与失败都收进 Exit 值,从不 reject——02 章会看到 Exit 的内部结构。

与下一个概念的关系:边界只开一个,内部成百上千个 effect 怎么串起来?靠组合。

1.5组合:pipe 与 Effect.gen

两种等价写法把小 effect 串成大 effect:pipe 链式,Effect.gen 用 yield* 像写 async/await 一样顺序书写。

为什么需要它

程序是很多步骤的依赖链:第二步要用第一步的结果。pipe + flatMap 能表达,但嵌套深了难读。Effect.gen 把"取出上一步结果"写成 yield*,读起来就是顺序代码——而背后仍是纯 Effect 组合,错误和依赖照样被类型追踪。

类比 · 带边界声明

yield* 之于 Effect,约等于 await 之于 Promise:都表示"等这一步的结果再继续"。边界在于——await 会真正触发执行,yield* 只是把这一步组合进描述,整个 gen 块本身还是个没运行的 Effect 值。

05-compose.tsTypeScript
import { Effect } from "effect"

// pipe 风格:数据从上往下流过一串算子
const withPipe = Effect.succeed(2).pipe(
  Effect.map((n) => n + 1),
  Effect.flatMap((n) => Effect.succeed(n * 10)),
)

// gen 风格:yield* 取出每一步的结果,像顺序代码
const withGen = Effect.gen(function* () {
  const n = yield* Effect.succeed(2)
  const m = yield* Effect.succeed(n + 1)
  return m * 10
})

// 两者等价:都是 Effect<number>,runSync 都得到 30
Effect.runSync(withPipe) // 30
Effect.runSync(withGen)  // 30
版本提醒

本教程用的是 yield* effect 的直接写法(需 TS 5.5+)。2024-04 之前的资料里会看到 Effect.gen(function* (_) { yield* _(eff) }) 那个 _ 适配器——它已被移除。看到 _ 就知道资料过时了。

与下一个概念的关系:到这里 E 通道一直是 never。把第一个真正的错误放进类型里,看会发生什么。

1.6类型化错误:第一印象

用 Data.TaggedError 定义带 _tag 的错误,Effect.fail 把它放进 E 通道,catchTag 按标签精确恢复。

为什么需要它

try/catch 抓到的是 unknown——你不知道抓到了什么,得靠 instanceof 一个个试。Effect 把每种错误的类型记在 E 通道里,catchTag("HttpError", ...) 只处理这一种,处理完编译器就把它从 E 里划掉。哪些错误还没处理,签名一目了然。

06-typed-error.tsTypeScript
import { Effect, Data } from "effect"

// 定义一个带 _tag: "HttpError" 的标签错误
class HttpError extends Data.TaggedError("HttpError")<{
  readonly status: number
}> {}

const fetchUser = (id: number) =>
  Effect.gen(function* () {
    if (id < 0) {
      return yield* Effect.fail(new HttpError({ status: 400 }))
    }
    return { id, name: "Ada" }
  })
// fetchUser(id): Effect<{ id: number; name: string }, HttpError>

const safe = fetchUser(-1).pipe(
  Effect.catchTag("HttpError", (e) =>
    Effect.succeed({ id: 0, name: `fallback(${e.status})` }),
  ),
)
// safe: Effect<{ id; name }, never>  —— HttpError 被处理掉,E 变回 never

Effect.runSync(safe) // { id: 0, name: "fallback(400)" }
  • Data.TaggedError("HttpError") 生成一个类,实例自带 _tag: "HttpError"——这个字符串标签是 catchTag 用来区分错误种类的钥匙。
  • fetchUser 的返回类型自动推断出 HttpError 在 E 通道里——你没手写,编译器从 Effect.fail 推出来的。
  • catchTag 处理掉 HttpError 后,safe 的 E 变回 never。处理错误会改变类型,这点 02 章深入。
想一想

如果 fetchUser 里还会 fail 一个 TimeoutError,但 safe 只 catchTag("HttpError", ...),safe 的类型会是什么?能 runSync 吗?

展开答案(先停 10 秒)

safe: Effect<..., TimeoutError>——E 通道里还剩 TimeoutError 没处理。runSync 仍能调用(它不要求 E=never,遇到失败会抛),但更诚实的做法是把 TimeoutError 也处理掉、或用 runPromiseExit 接住。关键点:没处理的错误一直留在类型里盯着你,这是 try/catch 给不了的。

与下一个概念的关系:E 通道讲完,轮到 R 通道——依赖怎么进入类型。

1.7服务与 Layer:第一印象

用 Context.Tag 声明一个服务接口,yield* 它会把依赖记进 R 通道;Layer 提供实现,Effect.provide 把 R 消除。

为什么需要它

业务代码里写死 new Database(),测试时就没法换成假实现。手动透传依赖(一层层传参数)很啰嗦。Effect 的做法:用 Tag 声明"需要一个 Clock",编译器把这个需求记进 R 通道;到边界处用 Layer 提供具体实现。业务代码只依赖接口,实现到最后才注入——测试给假的,生产给真的。

07-service-layer.tsTypeScript
import { Effect, Context, Layer } from "effect"

// 1. 声明服务:类名同时是"类型"和"运行时的键"
class Clock extends Context.Tag("app/Clock")<
  Clock,
  { readonly now: Effect.Effect<number> }
>() {}

// 2. 业务代码 yield* 这个 Tag —— 依赖被记进 R 通道
const program = Effect.gen(function* () {
  const clock = yield* Clock        // 取出 Clock 的实现
  const t = yield* clock.now
  return `now = ${t}`
})
// program: Effect<string, never, Clock>   注意 R = Clock

// 3. 提供实现
const ClockLive = Layer.succeed(Clock, { now: Effect.succeed(1_717_000_000_000) })

// 4. provide 把 R 从 Clock 消成 never —— 之后才能 run
const runnable = program.pipe(Effect.provide(ClockLive))
// runnable: Effect<string, never, never>

Effect.runSync(runnable) // "now = 1717000000000"
  • class Clock extends Context.Tag(...)<Clock, {...}>():这个类既是类型也是运行时的键。yield* Clock 按这个键取出实现。
  • yield* Clock 让 program 的 R 通道出现 Clock:类型在说"还缺一个 Clock 才能跑"。
  • Effect.provide(ClockLive) 填上实现,R 变回 never,runnable 这才能 runSync。R 不为 never 就 run 会编译报错——这是特性,不是 bug(04 章)。

与下一个概念的关系:A/E/R 三通道齐了。最后认识一下并发的原子——Fiber。

1.8Fiber:并发的原子

一个 Fiber 是 Effect runtime 里的轻量虚拟线程:可派生、可 join、可中断,比 Promise 多了"能取消"这件事。

为什么需要它

并发就是"同时跑多个程序"。Promise 能并发(Promise.all),但你拿不到把手——没法取消其中一个、没法在它被取消时跑清理。Fiber 给每个并发执行一个可观测、可中断的句柄。Effect.fork 派生一个 fiber,Fiber.join 等它的结果。原理和威力在 02 章展开,这里先建立印象。

08-fiber.tsTypeScript
import { Effect, Fiber } from "effect"

const slow = Effect.succeed("done").pipe(Effect.delay("1 second"))

const program = Effect.gen(function* () {
  const fiber = yield* Effect.fork(slow) // 派生 fiber,立即返回,不等待
  yield* Effect.log("fiber 已在后台运行")
  const result = yield* Fiber.join(fiber) // 需要结果时再等
  return result
})

Effect.runPromise(program) // 约 1 秒后得到 "done"
  • Effect.fork(slow):派生一个 fiber 在后台跑 slow,立即返回一个 Fiber 句柄,不阻塞。
  • Fiber.join(fiber):到真正需要结果时,等这个 fiber 完成。
  • 句柄的价值:拿着 fiber 还能 Fiber.interrupt(fiber) 取消它——而且取消时它注册的清理会运行。这正是 Promise 给不了的,02、03 章是重点。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里,再点开对照。直接点开 = 把这节当再读一遍。

  1. 用一句话说清:Effect 和 Promise 在"何时执行"上的根本区别是什么?
  2. Effect<string, never, never> 和 Effect<string, never, Database>,哪个能直接 runSync?为什么?
  3. Effect.sync 和 Effect.try 各用在什么场景?用错会怎样?
  4. 一个 effect 的 E 通道从 HttpError 变成 never,中间发生了什么?
答案(先做完再展开)
  1. Promise 构造即执行(热);Effect 是惰性的值,直到 run* 才执行(冷)。同一个 Effect 值 run 两次就执行两次。
  2. 前者。runSync(以及任何 run)要求 R=never——依赖已全部提供。后者 R=Database,必须先 Effect.provide 一个 Database 的 Layer 才能跑。
  3. sync 包"不会抛"的副作用;try 包"可能抛"的同步调用并把异常转进 E 通道。用 sync 包了会抛的代码,异常会变成绕过类型化处理的缺陷(defect)。
  4. 有人用 catchTag("HttpError", ...)(或其他恢复算子)把它处理掉了。处理错误会从 E 通道里减去对应类型——这是 Effect 错误模型的核心,02 章展开。
进阶挑战 · 刚好够不着

把一个 Promise 函数搬进 Effect

给定 async function getJSON(url: string): Promise<unknown>,它在网络失败时 reject。写一个 getJSONEff,使其类型为 Effect<unknown, NetworkError>(NetworkError 是你用 Data.TaggedError 定义的标签错误),网络失败时进入 E 通道而不是 reject。

提示(卡住再展开)

用 Effect.tryPromise({ try, catch }):try 调 getJSON(url),catch 把捕获到的 cause 包成 new NetworkError({ ... })。catch 的返回值类型决定了 E 通道。