为什么我用 Effect:从 Effect<A, E, R> 到 Effect.gen
这是「Effect v4 实战」系列的第一篇。我很喜欢 Effect,但我不打算在这里推销它,只想说清楚一件事:它解决的是 TypeScript 里一个长期被忽视的问题,也就是函数签名撒了谎。
本系列代码基于 effect 4.0.5 验证(tsc --strict 编译通过)。v4 和 v3 的 API 有不少差异,我会在遇到的地方标出来。
async/await 的签名里缺了什么
先看一个再普通不过的函数:
type User = { readonly id: string; readonly name: string }
async function getUser(id: string): Promise<User> {
try {
const res = await fetch(`https://api.example.com/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as User
} catch (e) {
// e 是 unknown:网络失败、HTTP 错误、JSON 解析失败,全混在一起
throw e
}
}它的类型是 (id: string) => Promise<User>。这个签名只告诉你成功时拿到什么,其余信息一概没有:
- 它会失败吗?会,但类型里看不到。
- 失败有几种?不知道。
catch里的e是unknown,要靠instanceof和字符串匹配去猜。 - 它依赖什么?这里依赖全局
fetch,换成数据库连接或配置,依赖就藏在模块级变量里。
换句话说,Promise 只有一个类型参数 T,代表成功值。错误和依赖这两个同样重要的维度,在类型系统里是不存在的。这不是写法问题,是 Promise<T> 这个类型表达能力不够。
这些问题在小项目里不明显,项目大了才会集中爆发:谁也说不清某个接口到底可能抛出哪些错误,每个调用点都只能「先 catch 一下再说」,要么吞掉,要么原样重抛。久而久之,错误处理变成了一种仪式,而不是设计。
Effect<A, E, R>:把三件事都写进类型
Effect 的核心类型有三个参数:
- A:成功时产出的值。
- E:预期内的失败(failure)类型,可以是联合类型。
- R:运行所需的环境(requirements),也就是依赖的服务。
Effect<User, HttpError, never> 读作:成功得到 User,可能以 HttpError 失败,不需要任何依赖。后两个参数有默认值,所以 Effect.Effect<number> 就是 Effect<number, never, never>。
另外一个关键性质:Effect 是惰性的描述,不是已经启动的计算。Promise 一创建就开始执行,Effect 只有交给 runtime 才会跑。
import { Effect } from "effect"
// 只是一段描述,创建时什么都不会打印
const hello = Effect.sync(() => console.log("hello"))
// 同一个描述可以被运行多次
const twice = Effect.gen(function* () {
yield* hello
yield* hello
})
Effect.runPromise(twice)惰性带来的好处是:重试、超时、并发、资源释放这些「围绕计算本身」的能力,都可以作为组合子套在描述外面,而不是手写一堆控制流。
用 Effect.gen 重写 getUser
Effect.gen 接受一个 generator 函数,yield* 的作用和 await 对应:取出成功值,失败则直接短路。
import { Data, Effect } from "effect"
type User = { readonly id: string; readonly name: string }
class NetworkError extends Data.TaggedError("NetworkError")<{
readonly cause: unknown
}> {}
class HttpError extends Data.TaggedError("HttpError")<{
readonly status: number
}> {}
class DecodeError extends Data.TaggedError("DecodeError")<{
readonly cause: unknown
}> {}
export const getUser = (
id: string
): Effect.Effect<User, NetworkError | HttpError | DecodeError> =>
Effect.gen(function* () {
const res = yield* Effect.tryPromise({
try: (signal) => fetch(`https://api.example.com/users/${id}`, { signal }),
catch: (cause) => new NetworkError({ cause })
})
if (!res.ok) {
return yield* new HttpError({ status: res.status })
}
return yield* Effect.tryPromise({
try: () => res.json() as Promise<User>,
catch: (cause) => new DecodeError({ cause })
})
})几个要点:
Effect.tryPromise把会 reject 的 Promise 转成 Effect,catch回调决定失败值的类型。这里每一类失败都有自己的名字。Data.TaggedError定义的错误可以直接yield*,等价于Effect.fail。这是 v4 里很顺手的写法。try回调拿到一个AbortSignal,Effect 被中断时会自动 abort 底层请求。用 async/await 要做到同样的事,得自己接线。- 返回类型的
E是一个联合类型。调用方看签名就知道要处理哪三种失败。
如果tryPromise不写catch,失败类型默认是Cause.UnknownError。
这个函数和前面的 async 版本做的事一样,区别是类型里多了诚实的信息。下一篇会专门讲怎么处理这些错误。
第三个参数 R:依赖是类型的一部分
依赖注入在 Effect 里不需要框架,它就是类型参数 R。在 v4 里,服务用 Context.Service 声明:
import { Context, Effect, Layer } from "effect"
class Logger extends Context.Service<Logger, {
readonly info: (message: string) => Effect.Effect<void>
}>()("app/Logger") {}
// R = Logger:类型上就写明了「没有 Logger 就跑不起来」
const program: Effect.Effect<number, never, Logger> = Effect.gen(function* () {
const logger = yield* Logger
yield* logger.info("computing")
return 42
})
const LoggerLive = Layer.succeed(Logger, {
info: (message) => Effect.sync(() => console.log(`[info] ${message}`))
})
// 提供依赖后 R 变成 never,才能交给 runtime
const runnable: Effect.Effect<number> = program.pipe(Effect.provide(LoggerLive))
Effect.runPromise(runnable).then(console.log)这里有两个值得注意的地方。
第一,yield* Logger 取出的是服务实例,同时 R 里自动出现了 Logger。你不需要手写这个类型,它由 Effect.gen 内部用到了什么推导出来。
第二,Effect.runPromise 只接受 R = never 的 Effect(v4 的签名是 Effect<A, E>)。忘了提供依赖,编译就报错,而不是运行时某个调用栈深处抛 undefined is not a function。
v3 到 v4 的差异:v3 的 Context.Tag("id")<Self, Shape>() 在 v4 里换成了 Context.Service<Self, Shape>()("id"),字符串 id 的位置也变了。照着旧博客抄会编译不过。
组合:在描述外面套能力
因为 Effect 是描述,超时和重试可以直接管道式叠加:
import { Effect } from "effect"
declare const fetchProfile: Effect.Effect<string, Error>
const resilient = fetchProfile.pipe(
Effect.timeout("2 seconds"),
Effect.retry({ times: 2 })
)Effect.timeout 会在失败类型里加上 TimeoutError,类型会如实反映这一点。你得到的不是一个「看起来能重试」的函数,而是一个类型已经更新过的新 Effect。
并排对比:同一件事,两种说法
把前面的内容压缩成一个对照,每一条都是我在日常写代码时真正在意的点:
- 失败的可见性:async 函数的失败只能靠阅读实现或文档得知;Effect 的失败写在
E里,改了实现签名就变,调用方立刻能感知。 - 失败的区分:
catch (e)里e是unknown,要靠instanceof或者读message字符串;Effect 里每种失败有自己的_tag,可以用catchTag精确处理。 - 依赖的显式程度:async 函数通过模块级 import 或闭包拿依赖,测试时要么 mock 模块,要么改全局;Effect 的依赖在
R里,测试时换一份实现提供进去即可。 - 取消与超时:Promise 本身不可取消,需要手动传
AbortController;Effect 的中断是内建语义,tryPromise回调拿到的signal会在中断时被触发。 - 执行时机:Promise 创建即执行,难以复用和重试;Effect 是惰性描述,同一个值可以安全地运行多次。
这五条里,我认为最重要的是前两条。它们决定了你在重构时是靠编译器还是靠记忆。
它不是银弹
说点不那么讨好的观点。
- 学习成本是真的。
yield*、pipe、Layer都要时间消化。如果项目只是几个脚本,async/await 完全够用。 - 边界要划清。Effect 擅长的是有失败、有依赖、有并发的业务逻辑;纯函数没必要包成 Effect。我的习惯是核心逻辑保持纯函数,副作用留在外层的 Effect 里。
- 团队要有共识。半个项目用 Effect、半个项目 try/catch,维护成本比统一一种风格更高。
还有一点要诚实地说:Effect 的类型提示有时比较长,初学时读报错需要适应。我的经验是给对外暴露的函数写显式返回类型,错误就会出现在离问题最近的地方,而不是传播到调用链顶端。
我选择 Effect 的理由很朴素:当错误和依赖都进了类型,重构时编译器能替我检查的东西变多了。
小结
Promise<T>只描述成功值,Effect<A, E, R>同时描述成功、预期失败和依赖。Effect.gen+yield*的写法和 async/await 几乎一一对应,但错误和依赖不会丢。- Effect 是惰性的描述,超时、重试这类能力可以直接组合。
- v4 的服务用
Context.Service声明,和 v3 的Context.Tag写法不同。
下一篇《Effect 类型化错误:TaggedError、catchTag 与 failure/defect 之分》:用 Data.TaggedError 建模失败,用 catchTag 精确恢复,并区分 failure 与 defect。