引言
TypeScript 的类型系统能描述数据的形状,却几乎无法描述「这段代码会抛什么错、依赖什么资源、是否会并发执行」。一个 Promise<User> 背后可能隐藏着网络超时、解析失败、鉴权过期,而类型签名对此只字不提——错误在运行时才暴露,依赖被 import 隐式耦合,并发语义靠注释口口相传。
Effect-TS 试图用类型把这三件事显式化:Effect<A, E, R> 分别表示「成功值 A、错误类型 E、所需依赖 R」。本文从副作用建模讲起,覆盖生成器语法、依赖注入、错误通道、并发原语、资源管理与重试调度,最后给出用 Effect 重构服务层的实践与陷阱。
前置:高级类型、Result 错误处理、异步并发控制。
目录
- 1. 为什么需要 Effect:副作用与错误通道
- 2. Effect 类型基础:三参数模型
- 3. 生成器语法:gen 与 yield
- 4. 依赖注入:Context 与 Layer
- 5. 错误处理:错误通道与 catchAll
- 6. 并发与并行:all、race、forEach
- 7. 资源管理:acquireRelease 与 Scope
- 8. 重试、超时与调度
- 9. 与 Promise 和 async 互操作
- 10. 实践:用 Effect 重构服务层
- 延伸阅读
1. 为什么需要 Effect:副作用与错误通道
1.1 Promise 类型的信息缺失
async function fetchUser(id: string): Promise<User> 这个签名没告诉你错误类型、依赖、超时——调用方只能读实现或撞运行时异常。
1.2 Effect 的答案
Effect<A, E, R> 三个类型参数:
A = 成功值类型(Success)
E = 错误类型(Error channel,never 表示不会失败)
R = 所需依赖(Requirements,never 表示无依赖)
import { Effect } from "effect"
type FetchUser = Effect.Effect<User, HttpError | ParseError, HttpClient>
// 成功 User ↑ ↑ 可能失败的两类错误 ↑ 需要注入 HttpClient
一句话总结:Effect 用
Effect<A, E, R>把「成功值、错误、依赖」三件事搬进类型签名——相比Result<T, E>,它多了依赖通道 R、惰性求值与并发/资源原语。
2. Effect 类型基础:三参数模型
2.1 构造一个 Effect
import { Effect } from "effect"
const ok = Effect.succeed(42) // Effect<number, never, never>
const bad = Effect.fail(new Error("boom")) // Effect<never, Error, never>
const parsed = Effect.try({
try: () => JSON.parse('{"a":1}') as unknown,
catch: (e) => new ParseError(String(e)), // 同步异常映射为 E 通道
})
2.2 惰性求值
Effect 是描述而非执行。Effect.succeed(1) 不会立即计算,只有 runPromise/runSync 才执行——同一个 Effect 可被复用、重试、组合,这与 Promise 的「创建即启动」形成根本差异。
const program = Effect.succeed(1).pipe(Effect.map((n) => n + 1))
// 此时什么都没发生
await Effect.runPromise(program) // 2
2.3 管道组合
import { pipe } from "effect"
const program = pipe(Effect.succeed(2), Effect.map((n) => n * 10))
一句话总结:Effect 是惰性的描述对象,
map/flatMap组合它、runPromise才执行——这与「Promise 创建即启动」截然不同。
3. 生成器语法:gen 与 yield
3.1 为什么需要 gen
flatMap 链在依赖上一步结果时会形成回调金字塔。Effect.gen 用生成器函数写出接近 async/await 的顺序代码,同时保留错误与依赖通道。
import { Effect } from "effect"
const program = Effect.gen(function* () {
const user = yield* fetchUser("1") // 自动 flatMap
const posts = yield* fetchPosts(user.id)
return { user, posts }
})
3.2 与 async/await 的对照
async/await Effect.gen
await → throw yield* → 错误进 E 通道,不抛
try/catch Effect.catchAll
隐式 Promise Effect<A, E, R> 显式三通道
3.3 yield* 的语义
yield* someEffect 等价于 Effect.flatMap(someEffect, ...):把成功值取出、错误短路、依赖合并到最终 R。
一句话总结:
Effect.gen用yield*写出顺序逻辑,错误走 E 通道而非抛出——比flatMap链可读,比async/await类型信息更全。
4. 依赖注入:Context 与 Layer
4.1 Context.Tag:声明依赖
import { Context, Effect } from "effect"
class HttpClient extends Context.Tag("HttpClient")<
HttpClient,
{ get: (url: string) => Effect.Effect<string, HttpError> }
>() {}
const fetchUser = (id: string): Effect.Effect<User, HttpError, HttpClient> =>
Effect.gen(function* () {
const http = yield* HttpClient
const raw = yield* http.get(`/users/${id}`)
return JSON.parse(raw) as User
})
Context.Tag 定义一个「接口 + 运行时 key」;用到它的 Effect,R 通道自动带上 HttpClient。
4.2 Layer:组装依赖
Layer 是「如何构造某个依赖」的配方,可组合:
import { Layer, Effect } from "effect"
const HttpClientLive = Layer.succeed(HttpClient, {
get: (url) => Effect.tryPromise({
try: () => fetch(url).then((r) => r.text()),
catch: (e) => new HttpError(String(e)),
}),
})
const UserRepoLive = Layer.effect(UserRepo, Effect.gen(function* () {
const http = yield* HttpClient
return { find: (id: string) => fetchUser(id) }
}))
const AppLive = UserRepoLive.pipe(Layer.provide(HttpClientLive))
await Effect.runPromise(program.pipe(Effect.provide(AppLive)))
一句话总结:
Context.Tag声明依赖、Layer描述如何构造、Effect.provide在程序边界注入——依赖从隐式 import 变成类型里的 R 通道。
5. 错误处理:错误通道与 catchAll
5.1 错误是值
Effect 中错误不抛出,而是作为 E 通道的类型。Effect.catchAll 消费错误:
const recovered = program.pipe(
Effect.catchAll((err: HttpError | ParseError) => Effect.succeed(defaultUser)),
)
5.2 区分错误类型
import { Effect, Data } from "effect"
class HttpError extends Data.TaggedError("HttpError")<{ status: number }> {}
class ParseError extends Data.TaggedError("ParseError")<{ raw: string }> {}
const handled = program.pipe(
Effect.catchTag("HttpError", () => Effect.succeed(fallback)),
Effect.catchTag("ParseError", (e) => Effect.fail(new FatalError(e.raw))),
)
Data.TaggedError 给错误加 _tag 判别字段,catchTag 精确匹配某一类;未处理的类型仍留在 E 通道,编译器会提醒你还有哪些错误没管。
5.3 错误与异常的边界
E 通道错误是可预期、类型已知的(HttpError、ValidationError);不可预期的缺陷(空指针、断言失败)走 Effect.die 的 defect 通道,绕过 E 通道。
一句话总结:Effect 把可预期错误放进 E 通道用
catchTag分类处理,不可预期的缺陷走 defect 通道——编译器帮你检查是否漏了某类错误。
6. 并发与并行:all、race、forEach
6.1 并行组合
import { Effect } from "effect"
// 并行执行,收集所有结果
const all = Effect.all([fetchUser("1"), fetchUser("2")], { concurrency: "unbounded" })
// 并发受限(最多 5 个同时)
const limited = Effect.forEach(ids, (id) => fetchUser(id), { concurrency: 5 })
6.2 竞速与结构化并发
const winner = Effect.race(fetchFromPrimary, fetchFromReplica) // 谁先成功用谁
const program = Effect.gen(function* () {
const fiber = yield* Effect.fork(longTask) // 启动子 fiber
return yield* Fiber.join(fiber) // 等待汇合
})
Fiber 是 Effect 的轻量线程。fork 启动、join 汇合、interrupt 取消,父 fiber 结束会级联取消子 fiber。
| 原语 | 语义 |
|---|---|
| all | 并行收集,全成功才成功 |
| race | 取最先完成者 |
| forEach | 并发映射(可限流) |
| fork/join | 结构化并发的启动与汇合 |
一句话总结:
all/race/forEach覆盖并行收集、竞速与限流映射,fork/join提供结构化并发——并发的取消与传播由运行时保证。
7. 资源管理:acquireRelease 与 Scope
7.1 自动释放
const withConn = Effect.acquireRelease(
openConnection(), // acquire
(conn) => Effect.sync(() => conn.close()), // release(保证执行)
)
7.2 Scope 保证释放顺序
Effect.scoped 划定资源生命周期,作用域退出时按逆序释放,即使中途失败或被中断:
const program = Effect.scoped(
Effect.gen(function* () {
const conn = yield* withConn
const tx = yield* beginTransaction(conn)
yield* tx.commit()
return "done"
}),
)
一句话总结:
acquireRelease+scoped把资源生命周期结构化——失败、中断、嵌套都保证逆序释放,比只保证同步栈释放的try/finally更可靠。
8. 重试、超时与调度
8.1 重试策略
import { Effect, Schedule } from "effect"
const retried = fetchUser("1").pipe(
Effect.retry(
Schedule.exponential("100 millis").pipe(Schedule.compose(Schedule.recurs(5))),
),
)
8.2 超时
const timed = fetchUser("1").pipe(Effect.timeout("2 seconds"))
// 超时后 E 通道加入 TimeoutException
8.3 组合 Schedule
// exponential 指数退避 / recurs(n) 限次 / spaced 固定间隔 / intersect 同时满足
const policy = Schedule.exponential("50 millis").pipe(
Schedule.intersect(Schedule.recurs(3)),
Schedule.jittered, // 加抖动避免惊群
)
一句话总结:
Effect.retry+Schedule把退避、限次、抖动组合成可复用策略,Effect.timeout把超时变成 E 通道的一种错误。
9. 与 Promise 和 async 互操作
9.1 边界转换
import { Effect } from "effect"
// Promise → Effect
const fromPromise = Effect.tryPromise({
try: () => fetch("/api").then((r) => r.json()),
catch: (e) => new HttpError(String(e)),
})
// Effect → Promise(在程序边界)
const value = await Effect.runPromise(program)
9.2 在 Effect 中调用 async 函数
const program = Effect.gen(function* () {
const data = yield* Effect.promise(() => someAsyncFn())
return data
})
Effect.promise 假设 Promise 不会 reject;会 reject 的场景用 Effect.tryPromise 并显式给出 catch 映射。
9.3 何时不该用 Effect
它适合复杂错误、依赖、并发、资源的中大型服务;脚本、简单 CRUD、团队不熟悉 FP、包体积敏感的场景则应回避。
一句话总结:Effect 在程序边界用
runPromise转成 Promise,内部用tryPromise/promise接入既有 async 代码——渐进式引入,不必全盘改造。
10. 实践:用 Effect 重构服务层
10.1 重构前后对比
// 重构前:隐式依赖、隐式错误、隐式并发
async function getUser(id: string) {
const conn = await pool.connect()
try {
const row = await conn.query("select * from users where id=$1", [id])
const profile = await fetch(`/profile/${id}`).then((r) => r.json())
return { ...row, ...profile }
} finally { conn.release() }
}
// 重构后:依赖/错误/资源全部进类型
const getUser = (id: string): Effect.Effect<Profile, DbError | HttpError, Db | HttpClient> =>
Effect.gen(function* () {
const db = yield* Db
const http = yield* HttpClient
const row = yield* db.query("select * from users where id=$1", [id])
const profile = yield* http.getJson(`/profile/${id}`)
return { ...row, ...profile }
})
10.2 常见陷阱
1. 忘记 runPromise:构造了 Effect 却没执行,程序静默无输出
2. 在 Effect.gen 中直接 await:应使用 yield*
3. 在内部层层 runPromise:破坏组合,应只在边界调用
4. 依赖未 provide:编译报 R 通道不满足,别用 as any 绕过
10.3 迁移路径
第一步:只在新模块引入 Effect,边界用 runPromise 暴露 Promise API
第二步:错误用 Data.TaggedError 分类,依赖抽成 Context.Tag + Layer
第三步:用 Effect.all / forEach 收敛并发
第四步:把重试、超时、资源管理替换为 Effect 原语
一句话总结:Effect 重构的核心是把「隐式依赖、抛出异常、手写 try/finally」换成「R 通道、E 通道、Scope」——渐进式迁移,边界保持 Promise 兼容。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。