TypeScript 错误处理工程化:从 try-catch 到 Result 模式的生产级实践

深入解析 TypeScript 应用中的错误处理模式,对比 try-catch、Result 类型、Effect 框架的性能与可维护性,附完整代码示例与生产环境避坑指南。

前端开发 2026-06-01 15 分钟

在 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 模式的真正威力在于组合性。通过 mapflatMapmatch 等高阶函数,可以像管道一样串联多个可能失败的操作,避免嵌套的 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) 成为类型安全的最后一个漏洞。

📚 相关文章