引言
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:两种错误模型
- 2. Result 类型设计
- 3. 类型化错误:判别联合
- 4. Option:处理「可空」而不是错误
- 5. async 错误传播与 await 陷阱
- 6. 自定义错误类与错误码
- 7. React 错误边界
- 8. 错误处理与可观测性
- 9. 工程实践:何时用哪种
- 10. 速查表
- 延伸阅读
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 |
| 并发错误 | allSettled |
| 渲染崩溃 | Error Boundary |
| 错误上下文 | requestId/userId 进日志 |
| 错误聚合 | 按 code/签名分组 |
| 禁止 | 静默 catch |
一句话记忆:TS 错误处理的关键是「让错误类型可见」——业务失败用 Result<T,E> 显式返回、意外故障用自定义 AppError 携带 code/status/details、可空用 Option/null;类型化错误用判别联合 + switch 穷尽保证「新错误必处理」;async 错误包成 Promise
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。