跳到正文

目录

Effect:把 TypeScript 从「能跑」带到「能交付」的类型安全运行时

Effect:把 TypeScript 从「能跑」带到「能交付」的类型安全运行时

核心判断

TypeScript 在工程上真正昂贵的不是编译期,而是运行期:错误到底抛没抛、依赖怎么注入、并发任务谁先取消、一条请求跨了几个服务怎么追踪。这些横切问题,每个项目各写各的,最后都沉淀成「我们内部有个工具函数」的私有债。Effect 想做的,是把这一整层横切能力收进一套统一的类型系统里——typed errors(类型化错误)、依赖注入、结构化并发(structured concurrency)、调度、tracing(追踪)和 Schema 校验,全部在同一套 Effect 类型上展开。截至 2026 年 10 月初,这个 TypeScript 库在 GitHub 上有 16.7k stars,当前 4.x 已被官方标为长期支持(LTS)版本。

这篇文章的价值不是「又一个好用的库」,而是帮你判断:你项目里那些散落的错误处理、依赖注入、并发取消、日志追踪代码,值不值得被一套统一抽象替换。

它解决的是什么问题

先看一个没有 Effect 的 TypeScript 业务函数长什么样:

async function createOrder(userId: string, items: Item[]) {
  try {
    const user = await db.user.find(userId); // 可能 throw
    const total = await pricing.calculate(items); // 可能 throw,也可能是别的异常
    await db.order.create({ userId, total });
    logger.info("order created", { userId, total }); // 只能靠字符串约定
  } catch (e) {
    // e 的类型是 unknown,你只能靠 instanceof 或 message 猜
    return { error: (e as Error).message };
  }
}

这段代码有几个工程上的痛点:错误类型在类型系统里是「失明」的(catch 拿到的是 unknown),依赖(db、pricing、logger)靠模块级单例或手动传参,并发与取消没有结构化表达,tracing 只能靠日志字符串约定。代码「能跑」,但难以组合、难以测试、难以在类型层面保证错误都被处理。

Effect 的回应是:把这些问题统一成同一个可组合的 Effect 类型,让编译器替你盯着错误路径、依赖清单和并发边界。

系统地图:一个核心 + 三层扩展

Effect 是一个 monorepo,以核心包 effect 为底座,向外扩展出平台、数据访问与 AI 三类集成包。把这张地图放在前面,后面讲机制时就不迷路。

层级包作用
核心effect错误处理、依赖注入、结构化并发、调度、tracing、Schema 校验
平台@effect/platform-node / -bun / -deno / -browser把运行时能力(文件、网络、时钟、日志)抽象成可注入的 Effect 服务
数据访问@effect/sql-*(pg / mysql2 / sqlite / clickhouse / d1 等)统一 SQL 客户端与事务抽象
AI 模块@effect/ai-openai / -anthropic / -cloudflare 等把 LLM 调用封装为 Effect,纳入同一错误与重试模型

核心包的四个机制值得单独拆开看。

类型化错误:错误成为函数签名的一部分

在 Effect 里,一个可能失败的计算在类型上就写明了它可能失败成什么样:

import { Effect } from "effect";

// Effect<成功值类型, 错误类型, 依赖类型>
const findUser = (id: string): Effect.Effect<User, UserNotFound> =>
  Effect.tryPromise({
    try: () => db.user.find(id),
    catch: () => new UserNotFound(id),
  });

UserNotFound 出现在类型签名里,意味着调用方在编译期就必须决定「处理它,还是把它继续向上传播」。这改变了错误处理的讨论方式:不再是在 catch 里靠 instanceof 猜,而是在类型层面把错误路径显式化。你可以在一个管道里用 Effect.retry、Effect.timeout、Effect.catchAll 组合出「重试三次、超时五秒、失败回退默认值」这类逻辑,而每一步的错误类型都保持可追踪。

依赖注入:依赖清单写在类型里

Effect 的依赖注入不是运行时反射,而是「类型里的依赖声明」。Effect<A, E, R> 的第三个类型参数 R 就是这个计算需要的服务集合(4.x 起成功值 A 排在第一位,v3 时代的 Effect<R, E, A> 顺序已调整)。组合两个 Effect 时,编译器自动合并它们的依赖要求;提供依赖用 Effect.provide,缺了哪个服务,类型检查直接报错。相比手动传参或模块单例,它的可测试性来自「换一个测试用的 service 实现」这一条路径。

结构化并发:取消与父子关系成为一等概念

Effect 的并发是结构化的:一个父 Effect 启动的子任务,其生命周期绑定在父任务上。父任务取消时子任务随之取消,不会出现「主流程已返回、后台 Promise 还在跑」的泄漏。这比 Promise.all 的「无法取消、一个 reject 其它照跑」模型更接近操作系统对进程的治理方式。调度器(scheduler)负责在何时何地执行这些任务,这也是它区别于裸 async/await 的关键:并发不是靠语言关键字,而是靠可组合的 Effect 原语(Effect.fork、Effect.race、Effect.all)。

Schema 与可观测性:边界校验和追踪进同一套系统

effect/Schema 提供运行时校验,但声明是类型化的:一个 Schema 既给出 TypeScript 类型,又是运行时校验器,适合用在 API 边界(外部输入不可信处)。tracing 则由 @effect/opentelemetry 把 Effect 的任务树映射到 OpenTelemetry span——因为并发是结构化的,span 的父子关系天然对齐任务树,不用手工维护 trace context。

一个请求如何流过系统

用一个「下单」场景把机制串起来。假设已有 db、pricing、logger 三个服务:

const createOrder = (userId: string, items: Item[]) =>
  Effect.gen(function* () {
    const user = yield* findUser(userId);
    const total = yield* pricing.calculate(items);
    yield* db.order.create({ userId, total });
    yield* Effect.log("order created", { userId, total });
  });

编译期能看到的事实:这个 Effect 依赖 db、pricing、logger 三个服务(类型参数 R),可能失败为 UserNotFound 或 PricingError(类型参数 E)。测试时把真实实现替换成内存假实现即可:

const testEnv = Effect.provideService(db, fakeDb).pipe(
  Effect.provideService(pricing, fakePricing),
  Effect.provideService(logger, testLogger),
);
const result = await Effect.runPromise(createOrder("u1", items).pipe(testEnv));

Effect.runPromise 是把 Effect 世界「降回」普通 Promise 的唯一出口,业务边界用它收敛。真实运行时的效果是:pricing.calculate 若为慢调用,可在 Effect 层加 Effect.timeout 或 Effect.retry;tracing 打开后,这条链路自动以任务树形态出现在 OpenTelemetry 里。

采用边界

Effect 不是银弹,它有自己的代价。先看它适合什么:

  • 长时间运行、错误路径复杂、依赖多的服务端应用——错误类型和依赖清单的显式化,长期维护收益明显。
  • 想统一技术栈的团队——SQL、AI 调用、平台 IO 都收敛到同一抽象,学习成本摊薄。
  • 愿意接受函数式思维迁移的团队——Effect.gen 的生成器语法降低了门槛,但仍要求开发者换一套组织代码的心智。

不适合或不划算的场景:

  • 一次性脚本、内部工具——引入 Effect 的成本高于收益。
  • 团队不愿改变错误处理习惯——类型化错误的前提是「错误必须被显式对待」,这与「到处 throw 然后在最外层兜底」的习惯冲突。
  • 纯前端轻量页面——Effect 的类型体操与运行时心智在简单 UI 上属于过度设计。

注意版本约束:Effect 4.x 要求 TypeScript 5.9 以上(官方推荐 7.x)、Node.js 18 以上,且必须开启 strict。部分集成包要求更新的运行时(例如 @effect/sql-sqlite-node 要求 Node.js 22.16+)。

采用建议

如果团队决定评估,推荐顺序是:先用 effect 核心包重写一条错误路径最复杂的业务链路,验证「错误类型化 + 依赖注入 + 可测性」三件事是否真实改善;跑通后再考虑引入 SQL 与 AI 集成包。不要第一周就全量替换——Effect 的价值在横切能力统一后才会放大,而这需要一段团队消化期。

一句话决策:如果「错误到底有哪些、依赖是谁、谁在并发里被取消」这三个问题在你的项目里要靠口头约定维护,Effect 值得认真评估;如果这些问题已经从没困扰过你,跳过它不亏。

参考

参与讨论

使用 GitHub 登录。欢迎补充事实、异议与实践。