Effect:把 TypeScript 从「能跑」带到「能交付」的类型安全运行时
posts posts 2026-10-04T03:30:00+08:00Effect 是构建生产级 TypeScript 应用的函数式库,用一套统一的类型系统解决错误处理、依赖注入、结构化并发、调度、可观测性与 Schema 校验。本文拆解其核心机制、包生态与采用边界。技术笔记TypeScript, Effect, 函数式编程, 错误处理, 开源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.com/Effect-TS/effect
- 官网文档:effect.website
- License:MIT
参与讨论
使用 GitHub 登录。欢迎补充事实、异议与实践。
讨论暂时无法加载。