在 TypeScript 项目中,超过 70% 的线上 Bug 来源于未正确处理的错误——网络请求失败、JSON 解析异常、第三方 API 返回意外格式,这些「正常流程之外」的情况往往被 try-catch 草草带过,最终导致用户看到白屏或数据丢失。TypeScript 的类型系统可以在编译期消除大量 Bug,但如果错误处理依然是「any catch any」的粗放模式,类型安全就形同虚设。
本文将从实际工程角度出发,对比三种主流错误处理模式,用真实代码演示它们在 API 调用、数据解析等场景中的表现,并给出明确的选型建议。
🔐 一、传统 try-catch 的工程痛点
1.1 类型黑洞与 Error Cause 链
TypeScript 4.4+ 将 catch 块中错误的默认类型改为 unknown,这是一个重要的类型安全改进。但现实中,大多数开发者的第一反应是用 catch (e: any) 绕过检查,因为「我不知道怎么处理 unknown 类型的错误」。这种做法让类型系统形同虚设。
更严重的问题是错误传递中的信息丢失。在一个典型的三层架构中,底层网络错误被包装成 API 错误,再被包装成业务错误。如果没有 Error Cause 链,你只能看到最外层的「获取用户失败」,完全丢失了「DNS 解析超时」这个根因。ES2022 引入的 Error.cause 正是为了解决这个问题。
// 支持 cause 链追踪的基础错误类
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
options?: ErrorOptions
) {
super(message, options)
this.name = this.constructor.name
}
// 递归获取完整的错误链,方便日志和监控
getChain(): string[] {
const chain: string[] = [this.message]
let current = this.cause
while (current instanceof Error) {
chain.push(current.message)
current = current.cause
}
return chain
}
}
class HttpError extends AppError {
constructor(public readonly status: number, options?: ErrorOptions) {
super(`HTTP ${status}`, 'HTTP_ERROR', options)
}
}
// 使用示例:构建完整的错误链
try {
const res = await fetch('/api/users/123')
if (!res.ok) throw new HttpError(res.status, { cause: await res.text() })
} catch (e: unknown) {
if (e instanceof AppError) {
console.error(e.getChain())
// 输出: ['HTTP 500', 'Internal Server Error']
}
}
⚠️ 警告: 永远不要在
catch块中throw new Error(e.message),这会丢失原始错误的堆栈信息和类型。始终使用{ cause: e }传递原始错误,这是构建可追踪错误系统的基础。
1.2 异步错误处理的陷阱
async/await 让异步代码看起来像同步代码,但错误处理有微妙差异。最常见的陷阱是 forEach 中的 async 回调——错误会被静默吞掉,因为 forEach 不会等待 Promise 完成。另一个陷阱是未处理的 Promise rejection:在 Node.js 中,默认行为是导致进程退出;在浏览器中,它只会触发一个 unhandledrejection 事件,不会阻止后续代码执行。
这意味着如果你在一个事件处理函数中 await 了一个可能失败的操作,但没有用 try-catch 包裹,错误可能会被静默吞掉,用户看不到任何反馈。
// ❌ 错误写法:forEach 中的 async 错误会被吞掉
async function processItems(items: string[]) {
items.forEach(async (item) => {
await processItem(item) // 如果抛错,错误消失得无影无踪
})
console.log('完成') // 会立即执行,不等待任何任务
}
// ✅ 正确写法:使用 Promise.allSettled 处理批量异步操作
async function processItems(items: string[]) {
const results = await Promise.allSettled(
items.map(item => processItem(item))
)
const failures = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
if (failures.length > 0) {
throw new Error(`${failures.length}/${items.length} 项处理失败`)
}
}
🚀 二、Result 模式:让错误成为类型的一部分
2.1 为什么需要 Result 类型
try-catch 的根本问题是:函数签名无法告诉你「这个函数可能失败」。当你看到 function fetchUser(id: string): Promise<User> 时,你不知道这个函数可能抛出网络错误、JSON 解析错误还是 HTTP 错误。调用方必须阅读源码或文档才知道需要处理哪些错误,这在大型项目中是不可维护的。
Result 模式通过将错误编码到返回类型中,强制调用方处理错误。函数签名变成 function fetchUser(id: string): Promise<Result<User, NetworkError | ApiError>>,调用方一眼就能看到可能的错误类型,不处理就编译不过。
这是函数式编程中的经典模式。Rust 的 Result<T, E>、Go 的 (value, error) 返回值、Kotlin 的 sealed class Result 都是这个思路的实现。TypeScript 完全可以用泛型实现同样的效果,而且实现非常简洁。
// Result 类型的核心定义
type Result<T, E extends Error = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
// 辅助构造函数
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value }
}
function Err<E extends Error>(error: E): Result<never, E> {
return { ok: false, error }
}
2.2 API 调用的 Result 重构
下面是 Result 模式在 API 调用场景中的完整实现。注意函数签名明确列出了所有可能的错误类型,调用方不需要阅读源码就能知道需要处理哪些异常情况。
// 定义精确的错误类型
class NetworkError extends AppError {
constructor(message: string, options?: ErrorOptions) {
super(message, 'NETWORK_ERROR', options)
}
}
class ApiError extends AppError {
constructor(
message: string,
public readonly status: number,
options?: ErrorOptions
) {
super(message, 'API_ERROR', options)
}
}
// 返回 Result 的 API 函数——错误类型一目了然
async function fetchUser(
id: string
): Promise<Result<User, NetworkError | ApiError>> {
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) return Err(new ApiError('用户查询失败', res.status))
return Ok(await res.json())
} catch (e: unknown) {
return Err(new NetworkError('网络请求失败', { cause: e }))
}
}
// 调用方必须处理错误——不处理就编译不过
async function displayUser(id: string) {
const result = await fetchUser(id)
if (!result.ok) {
// TypeScript 知道 result.error 是 NetworkError | ApiError
if (result.error instanceof ApiError && result.error.status === 404) {
showNotFound()
} else {
showError(result.error.message)
}
return
}
// TypeScript 知道 result.value 是 User
renderUser(result.value)
}
2.3 Result 的链式组合
Result 模式的真正威力在于组合性。通过 map、flatMap、match 等高阶函数,可以像管道一样串联多个可能失败的操作,避免嵌套的 if-else 金字塔。
// map: 转换成功值,失败时透传错误
function map<T, U, E extends Error>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
return result.ok ? Ok(fn(result.value)) : result
}
// match: 模式匹配,类似 Rust 的 match
function match<T, E extends Error, U>(
result: Result<T, E>,
handlers: { ok: (value: T) => U; err: (error: E) => U }
): U {
return result.ok ? handlers.ok(result.value) : handlers.err(result.error)
}
// 链式处理:获取用户头像,失败时使用默认值
async function getUserAvatar(userId: string): Promise<string> {
const result = await fetchUser(userId)
return match(map(result, user => user.avatarUrl), {
ok: url => url ?? '/default-avatar.png',
err: error => {
logError(error)
return '/default-avatar.png'
}
})
}
💡 提示: Result 模式特别适合「预期中的失败」,如 API 调用、输入验证、文件解析。对于「真正的异常」(如内存溢出、程序逻辑错误),使用 throw 仍然更合适。不要把所有错误都改成 Result,那样会让代码变得冗余。
📊 三、三种模式的工程对比与选型建议
3.1 全面对比
下表从学习成本、类型安全、组合性、生态兼容性等多个维度对比了三种错误处理模式。每种模式都有其适用场景,没有绝对的优劣之分。
| 维度 | try-catch | Result 类型 | Effect 框架 |
|---|---|---|---|
| 学习成本 | 最低 | 中等 | 高 |
| 类型安全 | 弱(catch 是 unknown) | 强(错误在类型中) | 最强(含依赖追踪) |
| 组合性 | 差(嵌套 try-catch) | 好(map/flatMap) | 最好(管道组合) |
| 生态兼容性 | 最好 | 需要适配层 | 需要全面采用 |
| 堆栈追踪 | 原生支持 | 需手动构建 | 内置 Trace |
| 包大小 | 0 KB | ~1 KB | ~50 KB |
| 适用场景 | 简单脚本、原型 | 中大型应用首选 | 复杂业务逻辑 |
⚡ 关键结论: 对于大多数 TypeScript 项目,Result 模式是最佳平衡点。它比 try-catch 更安全,比 Effect 更轻量,学习成本适中,且可以渐进式采用——不需要重写已有代码。
3.2 渐进式迁移策略
在已有项目中引入 Result 模式,不需要一次性重写所有代码。推荐按以下三步渐进迁移:
第一步:在 Service 层引入 Result
新增的 API 调用函数统一返回 Result,已有函数保持不变。这样新代码立即获得类型安全的错误处理,已有代码不受影响。
class UserService {
async getUser(id: string): Promise<Result<User, AppError>> {
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) return Err(new ApiError('获取用户失败', res.status))
return Ok(await res.json())
} catch (e) {
return Err(new NetworkError('网络错误', { cause: e }))
}
}
}
第二步:在 Controller/Component 层消费 Result
Vue 或 React 组件中统一使用 Result 模式处理 Service 层的返回值。错误处理变得可预测,不再有「意外的异常」导致组件崩溃。
async function loadUser() {
const result = await userService.getUser(userId.value)
if (!result.ok) {
toast.error(result.error.message)
return
}
user.value = result.value
}
第三步:统一错误上报
构建一个通用的 unwrapOrThrow 工具函数,在解包 Result 的同时自动上报错误到监控系统。这样既保留了 Result 的类型安全,又不会遗漏错误上报。
function unwrapOrThrow<T>(result: Result<T>, context: string): T {
if (result.ok) return result.value
Sentry.captureException(result.error, {
extra: { context, chain: result.error.getChain() }
})
throw result.error
}
3.3 什么时候该用哪种模式
选 try-catch 的场景: 快速原型和脚本、顶层错误兜底(全局错误边界)、调用不支持 Result 的第三方库、团队对函数式编程不熟悉。
选 Result 的场景: API 调用和数据获取、用户输入验证、文件和 JSON 解析、需要精确错误类型的服务层、中大型 TypeScript 项目。
选 Effect 的场景: 复杂的业务流程编排、需要依赖注入和测试隔离、需要内置重试和超时控制、团队有函数式编程基础。
3.4 相关工具推荐
| 库 | 特点 | 适用场景 |
|---|---|---|
| neverthrow | 轻量 Result 实现,API 简洁 | 中小型项目首选 |
| Effect | 全功能 Effect 系统,含并发、重试、依赖注入 | 复杂业务系统 |
| ts-results | Result 类型 + Rust 风格 API | 喜欢 Rust 的开发者 |
| Zod | 运行时类型校验 + 错误格式化 | 输入验证场景 |
✅ 总结
TypeScript 错误处理不是一个「有就行」的功能,而是直接影响应用健壮性和用户体验的核心工程问题。在生产环境中,一个未处理的错误可能导致整个页面白屏、用户数据丢失、甚至支付流程中断。好的错误处理不是「不报错」,而是「让调用方知道出错了、出了什么错、该怎么处理」。
常见的错误处理反模式包括:在 catch 中使用 any 导致类型安全失效、用 console.error 吞掉错误而不做任何恢复处理、在 forEach 中使用 async 导致错误被静默丢弃、以及不使用 Error Cause 链导致错误根因无法追踪。这些看似微小的问题,在生产环境中会累积成严重的可靠性隐患。
明确的建议如下:
- ✅ 新项目直接采用 Result 模式,在 Service 层和工具函数中全面使用
- ✅ 已有项目渐进式迁移,从新增的 API 调用开始,逐步扩展到更多层
- ✅ 始终使用 Error Cause 链,确保生产环境中的错误可追踪、可定位
- ❌ 不要在 catch 中使用
any,始终用unknown+ 类型收窄 - ❌ 不要混用 Result 和 throw,在同一层保持一致的错误处理风格
让 TypeScript 的类型系统真正为你的错误处理保驾护航,而不是让 catch (e: any) 成为类型安全的最后一个漏洞。