TypeScript 错误处理:Result 模式、类型化错误与错误边界实战

系统覆盖 TypeScript 的错误处理策略:异常 vs Result 返回对比、Result/Either 类型设计、类型化错误(判别联合)、Option 处理可空、async/await 错误传播、自定义错误类、React 错误边界、错误处理与日志/可观测性结合,以及错误处理的工程实践。

引言

JavaScript 的异常(throw/try-catch)简单,但问题也明显:错误是隐式的——函数签名看不出会抛什么错、错误处理散落在调用点、异步异常容易被吞。TypeScript 的价值之一,就是能把「错误」变成类型系统可见的结构:函数要么返回成功值、要么返回错误值(Result 模式),错误从「隐式的 throw」变成「显式的返回」。

本文系统讲 TS 错误处理:先对比异常与 Result 两种模型,再深入设计 Result/Either/Option 类型与类型化错误(判别联合),覆盖 async 错误传播、自定义错误类、React 错误边界,最后把错误处理与日志/可观测性结合,给出工程实践清单。

前置:/typescript/(TS 基础)、/typescript-advanced-types/(类型运算)、/typescript-runtime-validation-typesafe/(运行时验证)、/typescript-async-concurrency-control/(异步)。


目录


1. 异常 vs Result:两种错误模型

1.1 两种模型对比

维度异常(throw/catch)Result(返回)
错误可见性签名看不出会抛错返回类型明确 Ok/Err
传播方式隐式向上抛显式逐层返回
处理遗漏易遗漏(静默)编译器强制处理
控制流中断正常返回路径
复杂度简单需包裹层
适用意外错误预期错误(业务)

1.2 什么时候用哪个

预期错误(业务失败):表单校验、用户不存在、库存不足 → Result
意外错误(系统故障):数据库连接失败、Bug、磁盘满 → 异常
原则:能用 Result 表达的「业务分支」用 Result,
      无法预期的「系统崩溃」用异常

1.3 心智模型

异常 = 「不可能/不该发生」的事(崩溃)
Result = 「可能发生」的业务分支(状态)
好的 API:业务分支显式返回,真正异常才 throw

一句话总结:异常管「意外故障」、Result 管「预期业务分支」——签名里看得出错误的错误,比散落 try-catch 更可靠。


2. Result 类型设计

2.1 基础 Result

// 判别联合定义 Result
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }

// 构造辅助
export const ok = <T, E = never>(value: T): Result<T, E> => ({ ok: true, value })
export const err = <E, T = never>(error: E): Result<T, E> => ({ ok: false, error })

// 解包(收窄)
function unwrap<T, E>(r: Result<T, E>): T {
  if (r.ok) return r.value
  throw new Error(String(r.error))   // 解包失败视为 Bug
}

2.2 使用 Result

type UserError = 'NOT_FOUND' | 'UNAUTHORIZED'

function findUser(id: string): Result<User, UserError> {
  const user = db.get(id)
  return user ? ok(user) : err('NOT_FOUND')
}

const r = findUser('u1')
if (r.ok) {
  r.value.name    // ✅ 收窄后访问 value
} else {
  r.error         // ✅ 只能访问 error('NOT_FOUND' | 'UNAUTHORIZED')
}

2.3 map / flatMap 组合

// 让 Result 可组合
declare function map<T, E, U>(r: Result<T, E>, f: (v: T) => U): Result<U, E>
declare function flatMap<T, E, U>(
  r: Result<T, E>, f: (v: T) => Result<U, E>): Result<U, E>

const final = flatMap(
  map(findUser('u1'), u => u.name.toUpperCase()),
  name => ({ ok: true, value: `Hello ${name}` })
)
// 或者用 fp-ts / neverthrow 库(内置这些操作)

2.4 实用库

neverthrow:Result/Either 的工业实现(ok/err + 链式操作)
fp-ts    :函数式全套(Either/Option/TaskEither)
手写     :简单场景手写判别联合即可

一句话总结:Result 用判别联合显式表达「成功值 / 错误值」,map/flatMap 让组合无样板;复杂场景用 neverthrow/fp-ts。


3. 类型化错误:判别联合

3.1 错误即判别联合

type ApiError =
  | { kind: 'validation'; field: string; message: string }
  | { kind: 'auth'; reason: 'expired' | 'invalid' }
  | { kind: 'rate-limit'; retryAfter: number }
  | { kind: 'server'; code: number }

function callApi(): Result<Data, ApiError> {
  // ... 不同失败返回不同错误分支
  return err({ kind: 'validation', field: 'email', message: '格式错误' })
}

3.2 消费类型化错误

function handleError(e: ApiError): string {
  switch (e.kind) {
    case 'validation': return `${e.field}: ${e.message}`
    case 'auth':       return e.reason === 'expired' ? '登录过期' : '凭证无效'
    case 'rate-limit': return `请 ${e.retryAfter}s 后重试`
    case 'server':     return `服务异常(${e.code})`
  }
}

3.3 类型化错误的好处

1. 每种错误带「自己的数据」(field/reason/retryAfter)
2. switch 穷尽 → 新增错误类型强制补处理
3. 调用点能精确分支处理(提示/重试/上报)
4. 取代「错误码 int + 猜测」

一句话总结:类型化错误 = 判别联合 + 每分支自带数据,switch 穷尽保证「新错误必处理」——比错误码/字符串更可依赖。


4. Option:处理「可空」而不是错误

4.1 Option vs Result

Option<T> = Some(value) | None     —— 表示「可能没有」,不是失败
Result<T,E> = Ok(value) | Err(E)   —— 表示「可能失败」,带错误

例:findUser 用户可能不存在:
  「查询失败」→ Result(校验/连接错误)
  「查不到」  → Option(正常业务,无错误信息)
type Option<T> = { tag: 'some'; value: T } | { tag: 'none' }
// 或直接用 null/undefined 语义 + 严格 null 检查

4.2 用判别联合表达可空

type Option<T> = { tag: 'some'; value: T } | { tag: 'none' }

function first<T>(arr: T[]): Option<T> {
  return arr.length ? { tag: 'some', value: arr[0] } : { tag: 'none' }
}

const maybe = first(['a'])
if (maybe.tag === 'some') maybe.value   // ✅ 收窄

4.3 别过度设计

TS 的 null/undefined + strictNullChecks 已能表达大部分可空
Option 的价值在「显式、可组合」(map/flatMap)
简单场景用 nullable 即可,复杂链式用 Option

一句话总结:Option 表达「可能没有」(非错误),Result 表达「可能失败」(带错误)——分清两者,可空用 null 或 Option,失败用 Result。


5. async 错误传播与 await 陷阱

5.1 async 里的 Result

async function fetchUser(id: string): Promise<Result<User, ApiError>> {
  try {
    const data = await http.get(`/users/${id}`)
    return ok(data)
  } catch {
    return err({ kind: 'server', code: 500 })
  }
}

const r = await fetchUser('u1')
if (r.ok) r.value.name

5.2 async 的隐式错误

// 反例:async 函数内 throw → 变成 rejected promise,容易漏 catch
async function risky() { throw new Error('x') }
// 调用处若没 await+try,错误被吞或变 unhandled rejection

// 正例:边界 catch 一次,内部用 Result 传递

5.3 await 陷阱

// 坏:并发 await 串行
const a = await fetchA(); const b = await fetchB()   // 串行
// 好:先并行
const [a, b] = await Promise.all([fetchA(), fetchB()])
// 但 Promise.all 一个 reject 全 reject → 用 allSettled 保错误可见
const results = await Promise.allSettled([fetchA(), fetchB()])

一句话总结:async 错误要么包成 Result 显式返回、要么在边界统一 catch;并发用 allSettled 保留每个错误,避免一个失败吞掉全部。


6. 自定义错误类与错误码

6.1 自定义错误类

export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly status: number,
    public readonly details?: unknown,
  ) {
    super(message)
    this.name = 'AppError'
  }
}

// 使用
throw new AppError('用户不存在', 'USER_NOT_FOUND', 404, { id })

// 判断
if (e instanceof AppError) {
  e.code   // 类型安全访问 code/status/details
}

6.2 错误码设计

错误码三要素:code(稳定标识)+ status(HTTP)+ details(上下文)
设计原则:
  1. code 稳定(前端 if 判断用,不随文案变)
  2. 层级:模块前缀(USER_ / PAY_ / AUTH_)
  3. 详情结构化(details 携带字段,而非拼进 message)

6.3 异常 → Result 的边界转换

function callApiSafe(): Result<Data, ApiError> {
  try {
    return ok(api())
  } catch (e) {
    if (e instanceof AppError && e.code === 'RATE_LIMIT') {
      return err({ kind: 'rate-limit', retryAfter: e.details?.retryAfter ?? 60 })
    }
    return err({ kind: 'server', code: 500 })
  }
}

一句话总结:自定义错误类携带 code/status/details 让错误「可编程处理」;边界处把异常翻译成 Result 的错误分支,内外模型统一。


7. React 错误边界

7.1 错误边界(Error Boundary)

import { Component, type ReactNode } from 'react'

type State = { hasError: boolean; message: string }

class ErrorBoundary extends Component<{ children: ReactNode }, State> {
  state: State = { hasError: false, message: '' }

  static getDerivedStateFromError(e: Error): State {
    return { hasError: true, message: e.message }
  }

  componentDidCatch(e: Error, info: { componentStack?: string }) {
    // 上报可观测性
    reportError(e, info.componentStack)
  }

  render() {
    if (this.state.hasError) return <Fallback message={this.state.message} />
    return this.props.children
  }
}

7.2 边界位置与粒度

粒度:应用级(兜底)+ 路由级(每页)+ 组件级(局部)
原则:
  1. 关键页面有边界(崩溃不至于全站白屏)
  2. 局部可降级(单个组件挂了,其余继续)
  3. 边界内提供「重试」入口

7.3 错误边界不覆盖的场景

Error Boundary 捕获「渲染期」错误
不捕获:事件处理器、异步回调、SSR
这些场景用 try/catch 或 Result(事件处理里显式捕获)

一句话总结:Error Boundary 兜住渲染期崩溃、按「应用/路由/组件」分级部署;事件与异步错误仍用 Result/try-catch 显式处理。


8. 错误处理与可观测性

8.1 错误带上下文

function handleError(e: AppError, ctx: { requestId: string; userId: string }) {
  logger.error({
    event: 'app.error',
    code: e.code,
    status: e.status,
    requestId: ctx.requestId,
    userId: ctx.userId,
    stack: e.stack,
  })
}

8.2 错误分级上报

业务错误(USER_NOT_FOUND):info/warn,正常分支
系统错误(DB 连接失败)   :error,需告警
未知错误                 :error + 全量上报(不要静默)

8.3 错误码与监控聚合

按 code 聚合:看到「RATE_LIMIT 占比上升」→ 提前干预
错误签名:堆栈首几行哈希 → 同类错误归组
SLO:错误率超标 → 告警

一句话总结:错误处理与可观测性结合 = 错误带上下文(requestId/userId)+ 按级别上报 + 按 code/签名聚合监控。


9. 工程实践:何时用哪种

9.1 决策速查

场景方案
业务预期失败Result<T, ApiError>
查询可空null / Option
系统意外故障throw AppError
渲染崩溃Error Boundary
事件/异步回调try/catch 显式
跨层传递边界转换(异常→Result)

9.2 团队约定

1. 公共 API 返回值用 Result(业务分支可见)
2. 内部基础设施(DB/网络)用异常 → 边界转 Result
3. 错误码表统一维护
4. 不静默 catch:至少要 log
5. 错误信息用户可见 vs 内部可见分离

一句话总结:实践规则 = 公共 API 返回 Result、基础设施抛异常、边界转换、错误码统一、禁止静默 catch。


10. 速查表

需求方案
业务失败Result<T, E>
可空值null / Option
意外故障throw AppError
类型化错误判别联合 + switch 穷尽
async 错误Promise / 边界 catch
并发错误allSettled
渲染崩溃Error Boundary
错误上下文requestId/userId 进日志
错误聚合按 code/签名分组
禁止静默 catch

一句话记忆:TS 错误处理的关键是「让错误类型可见」——业务失败用 Result<T,E> 显式返回、意外故障用自定义 AppError 携带 code/status/details、可空用 Option/null;类型化错误用判别联合 + switch 穷尽保证「新错误必处理」;async 错误包成 Promise、并发用 allSettled 保留每个错误;React 渲染崩溃交给 Error Boundary,事件与异步用 try/catch;错误带上 requestId/userId 进日志、按 code 聚合监控——错误的类型化,是可靠系统的地基。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. TypeScript 测试策略:单元测试、类型测试与测试替身实战
  2. TypeScript 构建性能优化:增量编译、缓存与工具链选型
  3. TypeScript 库作者指南:声明文件、API 演进与包发布