Effect-TS 与函数式编程:类型化的副作用、依赖注入与并发

系统讲解 Effect-TS 在 TypeScript 中的应用:Effect 类型的三参数模型、生成器语法、Context 与 Layer 依赖注入、错误通道、并发原语、资源管理与重试调度,并给出用 Effect 重构服务层的工程实践与常见陷阱。

引言

TypeScript 的类型系统能描述数据的形状,却几乎无法描述「这段代码会抛什么错、依赖什么资源、是否会并发执行」。一个 Promise<User> 背后可能隐藏着网络超时、解析失败、鉴权过期,而类型签名对此只字不提——错误在运行时才暴露,依赖被 import 隐式耦合,并发语义靠注释口口相传。

Effect-TS 试图用类型把这三件事显式化:Effect<A, E, R> 分别表示「成功值 A、错误类型 E、所需依赖 R」。本文从副作用建模讲起,覆盖生成器语法、依赖注入、错误通道、并发原语、资源管理与重试调度,最后给出用 Effect 重构服务层的实践与陷阱。

前置:/typescript-advanced-types/(高级类型)、/typescript-error-handling-result/(Result 错误处理)、/typescript-async-concurrency-control/(异步并发控制)。


目录


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 兼容。


延伸阅读

  • /typescript-error-handling-result/ — Result 风格错误处理与 Effect 的对照
  • /typescript-advanced-types/ — 三参数模型背后的高级类型技巧
  • /typescript-async-concurrency-control/ — 异步并发控制与限流
  • /typescript-design-patterns-practice/ — 依赖注入与设计模式实践
  • /typescript-type-level-programming/ — 类型级编程与通道建模
  • TypeScript 专题 — TypeScript 专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. TS 中的 LLM 应用开发:AI SDK、流式响应、工具调用与类型安全
  2. 边缘运行时与适配器:Vercel Edge、Cloudflare Workers 与 Web API 兼容
  3. Node.js 性能剖析:V8 采样、clinic、火焰图与堆快照