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*。
run* 就是 Effect 全部威力的来源——在它之前,这个值可以被重试、超时、注入、测试。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 预期输出顺序:
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 才能跑"。
never 在三个位置都表示"空"——E=never 是"不会失败",R=never 是"无依赖、可以直接 run"。记住这个,02 章看错误被消除时就不会困惑。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 的类型系统里,从此错误和依赖才能被追踪。
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)}`),
})
Effect.sync 用于不会抛的副作用。如果包裹的函数其实会抛异常,用 Effect.try——否则异常会变成"缺陷"(defect)绕过你的类型化错误处理。这个区别是 02 和 04 章的重点。
与下一个概念的关系:造好了值,怎么让它真正跑起来?
1.4运行 effect:唯一的边界
run* 是把惰性 Effect 交给 runtime 执行的唯一出口,通常只在程序最外层调用一次。
组合、map、错误处理、依赖注入全发生在"Effect 世界"里,都不执行。run* 是这个世界与真实世界之间唯一的门。把门开在最外层(main、HTTP handler、CLI 入口),内部全是可组合、可测试的纯描述——这是 Effect 程序的标准形状。
run* 收敛到最外层,内部就全部可测试。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 值。
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 里划掉。哪些错误还没处理,签名一目了然。
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 提供具体实现。业务代码只依赖接口,实现到最后才注入——测试给假的,生产给真的。
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 章展开,这里先建立印象。
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
先合上教程,把答案写在纸上或编辑器里,再点开对照。直接点开 = 把这节当再读一遍。
- 用一句话说清:Effect 和 Promise 在"何时执行"上的根本区别是什么?
Effect<string, never, never>和Effect<string, never, Database>,哪个能直接runSync?为什么?Effect.sync和Effect.try各用在什么场景?用错会怎样?- 一个 effect 的 E 通道从
HttpError变成never,中间发生了什么?
答案(先做完再展开)
- Promise 构造即执行(热);Effect 是惰性的值,直到
run*才执行(冷)。同一个 Effect 值 run 两次就执行两次。 - 前者。
runSync(以及任何 run)要求 R=never——依赖已全部提供。后者 R=Database,必须先Effect.provide一个 Database 的 Layer 才能跑。 sync包"不会抛"的副作用;try包"可能抛"的同步调用并把异常转进 E 通道。用sync包了会抛的代码,异常会变成绕过类型化处理的缺陷(defect)。- 有人用
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 通道。