TypeScript 后端那些你自己手写的样板,Effect 一次性收掉

TypeScript 后端那些你自己手写的样板,Effect 一次性收掉

先看一段代码。我几乎可以肯定你写过它的某个版本------调一个外部接口、存库、带重试和超时

ts 复制代码
interface User { readonly id: string; readonly name: string }
type Result<T> = { ok: true; value: T } | { ok: false; error: Error }

export async function fetchUser(id: string, deps: Deps): Promise<Result<User>> {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 3000)
  let lastError: Error | undefined
  try {
    for (let attempt = 0; attempt < 3; attempt += 1) {
      try {
        const res = await deps.http.get(`/users/${id}`, { signal: controller.signal })
        if (!res.ok) throw new HttpError(String(res.status))
        const user = await res.json()
        await deps.db.save(user)
        deps.log.info("saved", { id })
        return { ok: true, value: user }
      } catch (cause) {
        lastError = cause as Error
        await new Promise((r) => setTimeout(r, 2 ** attempt * 100))
      }
    }
    return { ok: false, error: lastError ?? new Error("unknown") }
  } finally {
    clearTimeout(timer)
  }
}

Deps 是你项目里那个依赖对象------http 客户端、数据库、logger;HttpError 是你自己那个错误类。细节不重要,看形状就行。)

这段代码没有任何问题。它是我会写、你也会写的代码。我们来看的只是它的比例。


数一数:这里面有多少行是业务逻辑

业务逻辑只有四行:

  1. 请求 /users/${id}
  2. 解析 JSON
  3. 存库
  4. 记一条日志

剩下的全是搬运工AbortControllersetTimeout 拼出来的超时、手写的重试循环加指数退避、两层 try/catchResult 类型的包装和解包、deps 手动往下传。

这不是"代码写得不好"。这是把运行时的关注点,用运行时的手段解决------而 TypeScript 的类型系统在这些东西面前是瞎的。

问题的根子

看这个签名:

ts 复制代码
function fetchUser(id: string, deps: Deps): Promise<Result<User>>

调用它的人,能从中看出什么?

  • 会不会失败 ?看不出来。Result<T> 说了"可能失败",但失败成什么样、有哪几种,全靠读实现。
  • 需要什么依赖Deps 是个大对象,里面有什么得点进去看。
  • 有超时吗?看不出来。
  • 重试几次?看不出来。

所以每个项目都要把这些东西各写一套 ,而且写得都不一样。换个人接手,第一件事是把实现读一遍------因为类型没告诉他任何事

这就是为什么"换个项目就要重新学一遍"。

你可能会说:我们项目里有 withRetrywithTimeout 的封装,没这么难看。

对,那些封装确实省掉了重复的代码。但它们省不掉类型上的空白。看一个典型封装的签名:

ts 复制代码
function withRetry<T>(fn: () => Promise<T>, times: number): Promise<T>

调用方从 Promise<T> 里还是看不出:它会失败成哪几种、失败时抛的是什么、有没有超时。封装解决的是"代码重复",没解决"类型不表达"------而后者才是换个人接手就得重读实现的原因。

Effect 做的事情更彻底一点:它把这些信息放进类型本身,所以封装与不封装都不影响调用方能看到什么。

Effect 的做法:把它们搬进类型

(一句话说明:Effect 是 TypeScript 的一个库,它把这些"运行时关注点"变成类型上的信息,让编译器替你检查。它不管 HTTP、不管数据库,只管失败、依赖、并发、资源这四件事怎么被表达。)

同一件事,用 Effect 写:

ts 复制代码
import { Context, Data, Duration, Effect, Schedule, type Cause } from "effect"

// 错误类用 Effect 的约定定义(为什么必须这样,见文末「怎么开始」一节)
export class HttpError extends Data.TaggedError("HttpError")<{ readonly status: number }> {}
export class DbError extends Data.TaggedError("DbError")<{ readonly table: string }> {}

export const fetchUser = (id: string): Effect.Effect<
  User,                                          // 成功时给你什么
  HttpError | DbError | TimeoutException,        // 它可能怎么失败
  HttpClient | Database | Logger                 // 跑它之前必须提供什么
> =>
  Effect.gen(function* () {
    const http = yield* HttpClient
    const db = yield* Database
    const log = yield* Logger

    const res = yield* http.get(`/users/${id}`)
    const user = yield* res.json
    yield* db.save(user)
    yield* log.info("saved", { id })
    return user
  }).pipe(
    Effect.retry(Schedule.exponential(Duration.millis(100)).pipe(Schedule.compose(Schedule.recurs(3)))),
    Effect.timeout(Duration.seconds(3))
  )

上面那个签名不是我编的,是编译器会检查的 (这段代码可以编译通过,签名里的每个类型都对得上)。三个类型参数分别是什么,见《Effect 类型》yield* 那套写法见《使用 Generator》

对比一下这两段:

手写版 Effect 版
业务逻辑 4 行 6 行
搬运工 约 25 行 0 行
超时 手拼 AbortController .pipe(...) + Effect.timeout
重试 + 退避 手写循环 .pipe(...) + Effect.retry
错误有几类 类型上看不出来 写在签名里
需要什么依赖 Deps 大对象 写在签名里
漏掉依赖 运行时才发现 编译不过

三个"编译器会替你记住"的地方

这才是重点。上面那些省下来的行数只是方便,真正不一样的是下面这三件事会变成编译错误

① 忘了处理某种失败 → 编译不过

ts 复制代码
// 我声明它「不会失败」
const p: Effect.Effect<User, never, never> = fetchUser("1")
python 复制代码
error TS2322: Type 'Effect<User, HttpError | DbError | TimeoutException, HttpClient | Database | Logger>'
  is not assignable to type 'Effect<User, never, never>'.
  Type 'HttpError | DbError | TimeoutException' is not assignable to type 'never'.

没法假装它会失败的方式比实际少 。手写版里,Result<T> 只说了"可能失败";这里编译器把具体哪几种摆在你面前。

② 忘了提供依赖 → 编译不过

ts 复制代码
export const run = Effect.runPromise(fetchUser("1"))
python 复制代码
error TS2345: Argument of type 'Effect<User, DbError | TimeoutException, HttpClient | Database | Logger>'
  is not assignable to parameter of type 'Effect<User, DbError | TimeoutException, never>'.
  Type 'HttpClient | Database | Logger' is not assignable to type 'never'.
    Type 'HttpClient' is not assignable to type 'never'.

runPromise 只接受"不需要任何依赖"的 Effect。所以你不可能忘记注入 Database ------忘不掉,编译期就拦住了。手写版里,deps 少传一个字段是在运行时炸的。

依赖是怎么提供的、为什么要用 Layer,见《管理 Layer》runPromise 所在的程序边界见《运行 Effect》

③ 错误类型是签名的一部分,所以它不会悄悄消失

HttpErrorDbErrorTimeoutException 都在类型里。任何一个处理分支写错,编译器都会指出来。


代价,我也说清楚

上面这些听起来很美好,所以更要说清楚它不适合什么:

  • 不好学。 概念多(EffectLayerFiberScopeSchedule......),一开始写得别扭,报错也看不懂 ------因为报错默认你已经知道 Effect<A, E, R> 这三个位置是什么。
  • 写小脚本完全不值得。 一个 200 行的爬虫用 Effect,是给自己找麻烦。
  • 只有上面那四件事真的变成主要成本时,它才开始回本。 项目大了、人多了、要长期维护了、错误处理的正确性有要求了------这时候它才开始赚。

如果只是想做个小工具,请用你顺手的方式。 这不是客套话:Effect 的抽象是有成本的,用在它不划算的地方,是双输。

什么样的情况值得考虑

适合 不适合
服务要长期维护,会换人接手 一次性的脚本、原型
错误处理要求高(钱、订单、权限) 内部小工具
依赖多(数据库、缓存、外部 API、队列) 单文件、无外部依赖
需要并发、重试、超时、取消 简单的 CRUD
团队多人协作 只有你一个人且项目三个月就结束

一个粗略的判断:如果你发现自己在第三个项目里又写了一遍重试函数,那就是可以看看 Effect 的时候了。

怎么开始(别一上来就重写项目)

先装上(只有一个包,没有 tsconfig 插件要配):

bash 复制代码
npm i effect

最实际的第一步:找一个纯函数 ------一个会失败、可能有依赖的独立函数------用 Effect 重写它,然后在程序的边界 (HTTP handler / CLI 入口)用 Effect.runPromise 跑起来。

就这样。不要碰框架、不要动整个项目的架构。先让一个函数跑通,感受一下"类型里写着它会怎么失败"是什么体验。

一个一定要记住的实践 :错误类用 Data.TaggedError 定义,不要写 class X extends Error {}

ts 复制代码
import { Data } from "effect"

// ✘ 结构上与别的错误类完全相同
class HttpError extends Error {}
class DbError extends Error {}

// ✔ 带 _tag 判别字段
class HttpError extends Data.TaggedError("HttpError")<{ readonly status: number }> {}
class DbError extends Data.TaggedError("DbError")<{ readonly table: string }> {}

理由是实测的:两个没有各自字段的错误类是"结构相同"的类型 ,TypeScript 在联合类型里会把它们去掉一个------HttpError | DbError 会退化成其中一个,于是你既看不到完整的失败类型,也用不了 Effect.catchTag 按标签精确收窄。

ts 复制代码
// 用 Data.TaggedError 之后,收窄是可靠的:
Effect.catchTag(p, "HttpError", (e) => Effect.log(String(e.status)))
// 类型:Effect<void, DbError, never>   ← HttpError 被精确移除

这是 Effect 自己的约定,不是额外规矩。

另外建议把 Effect.gen 的返回类型显式写出来

ts 复制代码
export const fetchUser = (id: string): Effect.Effect<User, HttpError | DbError, HttpClient> =>
  Effect.gen(function* () { /* ... */ })

不是为了绕过什么推断问题------推断是对的------而是因为这个签名就是这个函数最重要的一句文档,写出来别人不用点进实现。


一句话总结

手写版把"会不会失败、需要什么依赖、超时几次"放在实现里 ,只有读过代码的人知道。 Effect 版把它们放在类型里,编译器帮你记着。

省下那 25 行搬运工只是顺带的好处。真正值钱的是:那些你以前只能在 code review 时靠人眼发现的东西,现在编译器会替你拦下来。


文中链接指向的是 effect-ts.cn ------ 官方文档的中文译文,共 234 页(v3 + v4),《创建 Effect》 这类入门页都在。每页标注了翻译时对照的上游版本。

站点是非官方社区站。有问题欢迎 提 issue

相关推荐
TunerT_TQ2 小时前
智能体评测的哲学——当“跑分”不再等于“能力”|第0期 · 序章
安全·架构·agent
JouYY2 小时前
我用DSH高效管理了我的prompt收藏
架构·llm·agent
烬羽2 小时前
单 Agent 是直线代码,多 Agent 是一张会分岔、循环、暂停的图:LangGraph 工作流编排
agent·ai编程
CoderJia程序员甲3 小时前
GitHub 热榜项目 - 周榜(2026-09-12)
ai·大模型·llm·github·agent
天涯明月19933 小时前
Agent Sandbox 深度解析——给会动手的 AI 一个安全的房间
人工智能·安全·大模型·agent·sandbox
七灵微3 小时前
【AI】Agent 安全:Skill、Tool、MCP 与运行时权限
人工智能·安全·agent
七夜zippoe4 小时前
AI Agent 的核心能力循环:感知→规划→执行→反思→记
人工智能·ai·agent·循环·核心能力
绘梨衣5474 小时前
多轮对话实体绑定故障复盘
ai·agent·多轮对话
leeyi5 小时前
Token 账单减下来:从路由到裁剪的五道闸门(第109篇)
aigc·agent·设计