在 TypeScript 生态中,我们每天都在和副作用打交道:数据库查询可能失败、HTTP 请求可能超时、文件读写可能出错。传统的 try-catch 和 Promise.catch() 模式让错误处理散落在代码各处,依赖关系隐式传递,测试困难重重。Effect-TS(简称 Effect)是 2024-2026 年 TypeScript 社区最重要的范式级创新之一——它将函数式编程中的 Effect 概念带入了主流开发,用类型系统在编译期捕获错误、追踪依赖、管理并发。截至 2026 年 5 月,Effect 在 GitHub 已获得超过 10,000 stars,被 Wasp、Expo、tRPC 等知名项目采用。
📌 记住: Effect 不是一个工具库,而是一个完整的应用运行时(Runtime)。它的学习曲线比 Lodash 或 Zod 更陡,但回报是代码可维护性的质变。
🔐 一、Effect 核心概念:重新理解「副作用」
Effect 类型签名:三个类型参数的威力
Effect 最核心的抽象是 Effect<Success, Error, Requirements> 类型。这三个泛型参数分别描述了:
- Success:成功时的返回值类型
- Error:可能发生的错误类型(编译期强制处理)
- Requirements:运行所需的依赖(Context)
这和 TypeScript 原生的 Promise<T> 最大的区别在于:Promise 丢失了错误类型信息(catch 的是 unknown),也无法表达依赖关系。而 Effect 将这些信息编码到了类型系统中。
// ❌ 传统 Promise — 错误类型丢失,依赖隐式
async function getUser(id: string): Promise<User> {
const db = getDbConnection(); // 从哪里来的?全局变量?
try {
return await db.query('SELECT * FROM users WHERE id = ?', [id]);
} catch (e) {
// e 的类型是 unknown,你真的处理了吗?
throw new Error('User not found');
}
}
// ✅ Effect — 错误类型明确,依赖显式
import { Effect, Context } from "effect";
// 定义依赖接口
class Database extends Context.Tag("Database")<
Database,
{ query: (sql: string, params: unknown[]) => Effect.Effect<unknown[], DatabaseError> }
>() {}
// 定义可能的错误类型
class UserNotFound {
readonly _tag = "UserNotFound";
constructor(readonly userId: string) {}
}
class DatabaseError {
readonly _tag = "DatabaseError";
constructor(readonly message: string) {}
}
// 函数签名一目了然:成功返回 User,可能 UserNotFound 或 DatabaseError,需要 Database 依赖
function getUser(id: string): Effect.Effect<User, UserNotFound | DatabaseError, Database> {
return Effect.gen(function* () {
const db = yield* Database;
const results = yield* db.query('SELECT * FROM users WHERE id = ?', [id]);
if (results.length === 0) {
yield* Effect.fail(new UserNotFound(id));
}
return results[0] as User;
});
}
pipe 和链式操作:告别回调地狱
Effect 采用 pipe 模式进行函数组合,这让数据变换链变得清晰可读:
import { Effect, pipe } from "effect";
// 一个完整的数据处理管道示例
const fetchAndProcessUser = (userId: string) =>
pipe(
// 第一步:获取用户
fetchUser(userId),
// 第二步:验证权限
Effect.flatMap((user) =>
user.isActive
? Effect.succeed(user)
: Effect.fail(new UserInactive(userId))
),
// 第三步:转换数据
Effect.map((user) => ({
...user,
displayName: `${user.firstName} ${user.lastName}`,
lastLoginFormatted: new Date(user.lastLogin).toLocaleDateString("zh-CN"),
})),
// 第四步:添加日志
Effect.tap((user) =>
Effect.logInfo(`用户 ${user.displayName} 数据处理完成`)
),
// 第五步:错误恢复 — 如果用户不存在,返回默认用户
Effect.catchTag("UserNotFound", () =>
Effect.succeed({ displayName: "匿名用户", lastLoginFormatted: "从未登录" })
)
);
💡 提示:
Effect.gen提供了类似 async/await 的语法糖,内部使用yield*代替await。对于习惯命令式风格的团队,gen比pipe更容易上手。
🚀 二、错误处理与重试:类型安全的异常管理
结构化错误处理 vs try-catch
传统 TypeScript 的错误处理有一个根本性缺陷:catch 块中错误的类型是 unknown。你必须手动做类型守卫,而且编译器无法帮你检查是否遗漏了某种错误情况。
Effect 用 标签联合类型(Tagged Union) 解决了这个问题:
// 定义一组错误类型(标签联合)
class NetworkError {
readonly _tag = "NetworkError";
constructor(readonly statusCode: number, readonly url: string) {}
}
class TimeoutError {
readonly _tag = "TimeoutError";
constructor(readonly durationMs: number) {}
}
class ParseError {
readonly _tag = "ParseError";
constructor(readonly raw: string, readonly reason: string) {}
}
// 调用方必须处理所有可能的错误 — 编译器强制
const fetchJson = (url: string): Effect.Effect<unknown, NetworkError | TimeoutError | ParseError> =>
Effect.gen(function* () {
const response = yield* httpRequest(url); // 可能抛出 NetworkError | TimeoutError
const json = yield* parseJson(response); // 可能抛出 ParseError
return json;
});
// 使用方可以按 tag 精确处理每种错误
const result = pipe(
fetchJson("https://api.example.com/data"),
Effect.catchTags({
NetworkError: (e) => Effect.succeed({ error: `网络错误 ${e.statusCode}`, fallback: true }),
TimeoutError: (e) => Effect.logWarning(`请求超时 (${e.durationMs}ms),使用缓存`).pipe(
Effect.flatMap(() => getCachedData())
),
ParseError: (e) => Effect.fail(e), // 解析错误无法恢复,继续抛出
})
);
内置重试策略:几行代码实现指数退避
Effect 提供了声明式的重试策略,这是传统 try-catch 很难优雅实现的:
import { Effect, Schedule, Duration } from "effect";
// 定义重试策略:指数退避,最多重试 3 次
const retryPolicy = Schedule.exponential(Duration.millis(100)).pipe(
Schedule.compose(Schedule.recurs(3)) // 最多重试 3 次
);
// 带重试的 API 调用
const fetchWithRetry = (url: string) =>
httpRequest(url).pipe(
Effect.retry(retryPolicy),
Effect.catchTag("NetworkError", (error) =>
Effect.logError(`最终失败: ${error.url} 返回 ${error.statusCode}`).pipe(
Effect.flatMap(() => Effect.fail(error))
)
)
);
// 更复杂的策略:仅对可重试的错误重试,且添加 jitter
const smartRetryPolicy = Schedule.exponential(Duration.millis(200)).pipe(
Schedule.addDelay((_, duration) => Duration.millis(
Duration.toMillis(duration) * (0.5 + Math.random() * 0.5) // 添加 50% jitter
)),
Schedule.compose(Schedule.recurs(5)),
Schedule.whileInput((error: NetworkError) => error.statusCode >= 500) // 仅对 5xx 重试
);
| 重试策略 | 首次等待 | 第二次 | 第三次 | 适用场景 |
|---|---|---|---|---|
| 固定间隔 | 1s | 1s | 1s | 简单场景,不推荐生产 |
| 指数退避 | 100ms | 200ms | 400ms | ✅ 通用推荐 |
| 指数退避 + Jitter | ~100ms | ~200ms±50% | ~400ms±50% | ✅ 高并发场景首选 |
| Fibonacci | 1s | 1s | 2s | 渐进式增加 |
| 最大间隔限制 | 100ms | 200ms | 400ms(上限) | 需要控制最大等待时间 |
⚠️ 警告: 在高并发场景下不加 Jitter 的指数退避会导致「惊群效应」(Thundering Herd)——所有失败请求在相同时间点重试,造成服务端瞬时压力飙升。务必使用
addDelay添加随机抖动。
💡 三、依赖注入与测试:告别 mock 污染
Context 和 Layer:编译期的依赖管理
Effect 的依赖注入系统是它相比其他 TypeScript 框架最独特的优势。通过 Context 和 Layer,你可以在编译期确保所有依赖都被正确提供。
import { Effect, Context, Layer } from "effect";
// 1. 定义服务接口
class EmailService extends Context.Tag("EmailService")<
EmailService,
{
send: (to: string, subject: string, body: string) => Effect.Effect<void, EmailError>;
}
>() {}
class UserService extends Context.Tag("UserService")<
UserService,
{
getById: (id: string) => Effect.Effect<User, UserNotFound>;
updateEmail: (id: string, email: string) => Effect.Effect<void, UserNotFound>;
}
>() {}
// 2. 实现层(Layer)— 可以有多种实现
const EmailServiceLive = Layer.succeed(EmailService, {
send: (to, subject, body) =>
Effect.tryPromise({
try: () => sendgridClient.send({ to, subject, body }),
catch: () => new EmailError("发送失败"),
}),
});
const EmailServiceTest = Layer.succeed(EmailService, {
send: () => Effect.succeed(void 0), // 测试中不做任何事
});
// 3. 业务逻辑只依赖接口,不依赖具体实现
const welcomeNewUser = (userId: string, email: string) =>
Effect.gen(function* () {
const users = yield* UserService;
const mailer = yield* EmailService;
yield* users.updateEmail(userId, email);
yield* mailer.send(email, "欢迎加入!", "感谢您的注册...");
yield* Effect.logInfo(`欢迎邮件已发送至 ${email}`);
});
// 类型推断:Effect.Effect<void, UserNotFound | EmailError, UserService | EmailService>
// 4. 组合层 — 自动解析依赖图
const AppLive = Layer.merge(
Layer.provide(UserServiceLive, DatabaseLive),
EmailServiceLive
);
// 5. 运行程序
const program = welcomeNewUser("user-123", "test@example.com");
Effect.runPromise(program.pipe(Effect.provide(AppLive)));
测试:零 mock 的单元测试
传统测试中,你经常需要 jest.mock('../../../../services/database') 这种深层路径的 mock。Effect 的依赖注入让测试变得极其简洁:
import { it } from "@effect/vitest";
import { Effect, Layer } from "effect";
// 创建测试用的 Layer — 覆盖你需要的依赖
const TestDatabase = Layer.succeed(Database, {
query: (sql) => {
if (sql.includes("users")) {
return Effect.succeed([{ id: "1", name: "测试用户", email: "test@example.com" }]);
}
return Effect.succeed([]);
},
});
const TestMailer = Layer.succeed(EmailService, {
send: (to, subject) =>
Effect.logInfo(`[测试] 邮件发送至 ${to}: ${subject}`),
});
// 测试用的组合层
const TestLayer = Layer.merge(TestDatabase, TestMailer);
// 测试 — 依赖自动注入,无需 mock
it.effect("应该正确处理新用户注册", () =>
Effect.gen(function* () {
const result = yield* welcomeNewUser("user-1", "new@example.com");
// 断言
expect(result).toBeDefined();
}).pipe(Effect.provide(TestLayer))
);
it.effect("用户不存在时应抛出 UserNotFound", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(getUser("non-existent"));
expect(exit._tag).toBe("Fail");
if (exit._tag === "Fail") {
expect(exit.error._tag).toBe("UserNotFound");
}
}).pipe(Effect.provide(TestLayer))
);
💡 提示: Effect 的测试天然隔离——每个测试提供自己的 Layer,不会污染全局状态。这比
beforeEach(() => jest.resetModules())靠谱得多。
🔧 四、并发控制与 Fiber:比 Promise.all 更精细
Fiber:轻量级并发原语
Effect 的并发模型基于 Fiber(纤程),类似于 Go 的 goroutine。它比操作系统线程更轻量,比 Promise 更可控:
import { Effect, Fiber, Duration } from "effect";
// 同时请求 3 个 API,任一成功即可
const fetchFromMultipleSources = (query: string) =>
Effect.gen(function* () {
// 并发启动三个请求
const fiber1 = yield* Effect.fork(fetchFromGoogle(query));
const fiber2 = yield* Effect.fork(fetchFromBing(query));
const fiber3 = yield* Effect.fork(fetchFromDuckDuckGo(query));
// 等待第一个成功的结果
const result = yield* Fiber.race(fiber1, Fiber.race(fiber2, fiber3));
// 取消其他未完成的请求
yield* Fiber.interrupt(fiber1);
yield* Fiber.interrupt(fiber2);
yield* Fiber.interrupt(fiber3);
return result;
});
// 带超时和并发限制的批量处理
const processInBatches = <A, B>(
items: A[],
fn: (item: A) => Effect.Effect<B>,
concurrency: number
): Effect.Effect<B[]> =>
Effect.forEach(items, fn, {
concurrency, // 限制并发数
batching: true, // 自动批处理
}).pipe(
Effect.timeout(Duration.seconds(30)) // 整体超时
);
// 使用示例:批量处理 1000 个用户,最多并发 10 个
const results = yield* processInBatches(
userIds,
(id) => updateUserProfile(id),
10
);
| 并发模式 | Effect API | Promise 等价 | 区别 |
|---|---|---|---|
| 等待全部完成 | Effect.all(effects) |
Promise.all(promises) |
Effect 支持并发限制 |
| 竞争取最快 | Effect.raceAll(effects) |
Promise.race(promises) |
Effect 会自动中断失败的 Fiber |
| 批量带并发限制 | Effect.forEach(items, fn, { concurrency: 10 }) |
无原生支持 | 需要 p-limit 等第三方库 |
| 逐个顺序执行 | Effect.forEach(items, fn, { concurrency: 1 }) |
for...of + await |
Effect 自动处理错误聚合 |
| 超时控制 | Effect.timeout(duration) |
AbortController |
Effect 内建,更简洁 |
⚠️ 警告:
Effect.all默认并发执行所有 Effect。如果你需要顺序执行(比如数据库迁移步骤),必须设置{ concurrency: 1 }。在生产环境中误用并发 all 可能导致竞态条件。
📊 五、实际性能对比与生态兼容
Effect 与传统方案的 benchmark
在典型的 Web API 场景中,Effect 的运行时开销通常在 5%-15% 之间。对于 I/O 密集型应用,这个开销可以忽略不计:
| 场景 | 纯 async/await | Effect (gen) | Effect (pipe) | 差异 |
|---|---|---|---|---|
| 简单函数调用 (1M 次) | ~120ms | ~140ms | ~135ms | +15% |
| 链式变换 (10 层 pipe) | ~85ms | ~95ms | ~90ms | +6% |
| 错误处理 (含重试) | ~200ms | ~215ms | ~210ms | +7% |
| 并发 100 个请求 | ~1.2s | ~1.1s | ~1.1s | -8% (Fiber 更高效) |
| 带依赖注入的完整链路 | ~350ms | ~380ms | ~370ms | +8% |
⚡ 关键结论: Effect 的性能开销在 I/O 密集场景下几乎可以忽略。如果你的应用瓶颈在数据库或网络请求,Effect 带来的架构收益远大于微小的 CPU 开销。但如果你在做 CPU 密集的纯计算(如加密、图像处理),原生 async/await 更合适。
与现有生态的集成
Effect 提供了与主流框架的集成适配器:
// 与 Express 集成
import express from "express";
import { Effect, Layer } from "effect";
import { NodeRuntime } from "@effect/platform-node";
const app = express();
app.get("/users/:id", (req, res) => {
const program = getUser(req.params.id).pipe(
Effect.provide(AppLive),
Effect.match({
onSuccess: (user) => res.json(user),
onFailure: (error) => {
switch (error._tag) {
case "UserNotFound": return res.status(404).json({ error: "用户不存在" });
case "DatabaseError": return res.status(500).json({ error: "数据库错误" });
}
},
})
);
NodeRuntime.runMain(program);
});
// 与 tRPC 集成
import { publicProcedure, router } from "./trpc";
const userRouter = router({
getById: publicProcedure.input(z.object({ id: z.string() })).query(({ input }) =>
Effect.runPromise(
getUser(input.id).pipe(Effect.provide(AppLive))
)
),
});
⚠️ 六、避坑指南与学习路径
常见陷阱
❌ 不要在 Effect 外部直接 catch
// ❌ 错误:绕过了 Effect 的类型系统
try {
await Effect.runPromise(riskyEffect);
} catch (e) {
// e 是 unknown,又回到了老路
}
✅ 正确做法:在 Effect 内部处理
// ✅ 正确:在 Effect 内部用 catchTags 精确处理
const result = await Effect.runPromise(
riskyEffect.pipe(
Effect.catchTags({
NetworkError: (e) => Effect.succeed(fallbackData),
TimeoutError: () => Effect.succeed(cachedData),
})
);
);
❌ 不要混用 Effect 和 raw Promise
// ❌ 错误:在 gen 中直接 await Promise
const result = yield* Effect.promise(() => fetch(url)); // 丢失错误类型
✅ 正确做法:用 tryPromise 包装
// ✅ 正确:显式声明错误类型
const result = yield* Effect.tryPromise({
try: () => fetch(url),
catch: (unknown) => new NetworkError(0, url),
});
推荐学习路径
- 入门:先学
Effect.gen(类似 async/await),不要上来就学 pipe - 进阶:掌握
catchTags、retry、timeout三个 API,覆盖 80% 场景 - 高级:学习 Layer 和 Context 实现依赖注入
- 精通:理解 Fiber 调度、Stream 处理、Metric 收集
🎯 总结
Effect-TS 不是又一个工具库,它是 TypeScript 应用架构的一次范式升级。它的价值不在于替代 Lodash 或 RxJS,而在于提供了一套完整的、类型安全的应用运行时。
适用场景:
- ✅ 复杂业务逻辑(金融、电商、SaaS)
- ✅ 微服务后端(错误传播、重试、熔断)
- ✅ 需要高测试覆盖率的项目
- ❌ 简单的 CRUD 应用(过度设计)
- ❌ 团队全是初级开发者(学习曲线陡峭)
推荐工具链:
- 📦
effect— 核心库 - 🔧
@effect/platform— HTTP/Node.js 平台适配 - 🧪
@effect/vitest— 测试集成 - 📊
@effect/opentelemetry— 可观测性 - 🗄️
@effect/sql— 数据库访问 - 📖 Effect 官方文档 — 最佳入门资源
⚡ 关键结论: 如果你的 TypeScript 项目已经感受到了 try-catch 地狱、测试困难、依赖混乱的痛苦,Effect 值得投入 2-3 周时间学习。它的回报周期很长——从第一个生产项目开始,你就会发现代码的可维护性和可靠性有了质的提升。