在日常的前端开发中,我们往往更关注"业务逻辑正确路径"的实现,而忽略了对业务错误的处理。久而久之,代码中出现了以下问题:
- 错误处理不统一:不同人处理错误的方式不一致,有的 throw,有的 return,有的直接吞掉。
- 错误遗漏:某些错误没有被处理,导致线上异常难以追踪。
- 错误类型混乱:错误信息不规范,缺乏结构化,无法统一处理。
为了解决这些问题,我尝试引入了一个概念:Checked Exception(受检异常) ,并通过 TypeScript 库 ts-results
来模拟这一模式,实现更清晰、更可控的错误处理方式。
一、什么是 Checked Exception?和 try/catch 有什么区别?
1. Unchecked Exception(非受检异常)------我们常用的 try/catch
- 在 JS 中,我们常用
try/catch
来捕获异常,简单直接。 - 它属于 Unchecked Exception ,意思是:你不需要显式声明可能会抛出的错误类型。
- 这虽然方便,但也容易导致错误处理被遗漏,尤其是多人协作的项目。
2. Checked Exception(受检异常)------强制处理错误
- 这个概念来源于 Java、Rust 等语言。
- 它的核心思想是:函数返回值中明确包含"成功"或"失败"的类型,调用者必须处理所有可能的错误情况。
- 在 JS/TS 中,我们无法直接使用 Checked Exception,但可以用
ts-results
来模拟。
二、ts-results 是什么?怎么用?
ts-results
是一个轻量级库,模拟了 Rust 中的 Result<T, E>
类型。它的核心是:
ts
type Result<T, E> = Ok<T> | Err<E>;
你可以把它理解为一个"带错误信息的成功/失败结果",调用者必须判断是成功还是失败。
1. 安装
npm install ts-results
2. 基本用法
ts
import { Ok, Err, Result } from 'ts-results';
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return new Err('除数不能为0');
}
return new Ok(a / b);
}
const result = divide(10, 0);
if (result.ok) {
console.log('结果是:', result.val);
} else {
console.error('出错了:', result.val);
}
三、ts-results 的好处
强制处理错误
- 使用
Result
返回值后,开发者必须显式处理成功和失败两种情况。 - 不像
throw
那样可以"漏掉",也不像try/catch
那样容易"吞掉"。
类型安全
Result<T, E>
中的T
和E
都是类型,TypeScript 会帮你做类型推导。- 例如,如果你返回的是
Result<User, AuthError>
,那调用者就知道可能的错误类型是AuthError
。
链式处理错误
map
,mapErr
,andThen
等方法让错误处理更简洁。- 举个例子:
ts
function getUser(): Result<User, Error> { ... }
function getUserName(): Result<string, Error> {
return getUser()
.map(user => user.name);
}
四、如何在项目中落地 ts-results?
我们可以把一个页面的技术架构分为几个层次,每一层都使用 Result
来处理错误:
在我们的项目中,一个页面一般从下到上分为 5 层:
- 服务层 - HTTP 服务
- 服务层 - 接口对接
- 服务层 - 数据处理
- 交互层 - 数据 Store
- 交互层 - UI 组件
在服务层的 HTTP 服务与接口对接会根据系统与业务的要求设定预先设定好的 Error,例如:Axios Error,请求参数错误 Error,远程处理错误 Error,预定义的带有错误码的 Error 等等。
在数据处理层,会对下层的 Error 进行聚合与预处理,例如将 Axios Error,请求参数错误 Error,远程处理错误 Error 聚合,产生一个新的错误,系统服务 Error。对其他的错误放行
在数据 Store 中会处理下层传上来的所有 Error,按照业务需要分别处理,对无法处理的错误 unwrap,throw 到顶层,由 sentry 捕获
服务层 - HTTP 请求封装
ts
function processResponse(url: string, resp: AxiosResponse) {
if (resp && resp.status === 200) {
if (resp.data.IsSuccess) {
return new Ok(resp.data.Value);
} else {
return new Err(new CustomRespError(resp.data.Code, resp.data.Message, url));
}
} else {
if (resp.status === 404) {
return new Err(new PageRedirectError(404, ''));
} else if ((resp.status = 403)) {
return new Err(new PageRedirectError(403, ''));
} else if (resp.status === 401) {
return new Err(new NoLoginError(resp.statusText));
} else {
return new Err(
new PageRedirectError(500, `服务错误, 请稍后重试, 状态码 [${resp.status}]`),
);
}
}
}
function processError(e: Error) {
if (axios.isAxiosError(e)) {
e as AxiosError;
if (e.code === AxiosError.ERR_NETWORK || e.code === AxiosError.ETIMEDOUT) {
return new Err(new NetworkError(e.message));
}
...
}
export async function get<T>(url: string): Promise<Result<T, Error>> {
try {
return processResponse(url, await instance.get<Resp<T>>(url));
} catch (e: any) {
return processError(e);
}
}
export async function post<T>(url: string, data: any): Promise<Result<T, Error>> {
try {
return processResponse(url, await instance.post<Resp<T>>(url, data));
} catch (e: any) {
return processError(e);
}
}
可以看到在这里,对系统和接口返回的错误进行了封装,分别分装为:
* PageRedirectError
需要页面跳转类的错误
* NoLoginError
未登录类的错误
* NetworkError
系统网络类错误
* CustomRespError
业务预定义错误
错误的定义
js
export class PageRedirectError extends Error {
status: number;
msg: string;
constructor(status: number, msg: string) {
super(`PageRedirect: ${status} - ${msg}`);
this.status = status;
this.msg = msg;
}
toString() {
return `网络错误(${this.status}),请刷新后重试`;
}
}
...
export class CustomRespError extends Error {
code: string;
msg: string;
path: string;
constructor(code: string, msg: string, path: string) {
super(`CustomRespError: ${code}`);
this.code = code;
this.msg = msg;
this.path = path;
}
toString() {
return this.msg;
}
static checkCode(err: Error, code: string): boolean {
if (err instanceof CustomRespError) {
return err.code === code;
}
return false;
}
}
接口对接层 - 业务逻辑封装
ts
async function fetchList(): Promise<Result<List, Error>> {
const result = await get('/api/list');
return result.andThen(data => {
if (data.list.length === 0) {
return new Err(new CustomRespError('DATA-0000', '无数据', '');
}
return new Ok(data);
});
}
交互层
js
export class State {
...
constructor() {
makeAutoObservable(this);
}
mounted() {
...
this.getTodoTasks();
}
async getTodoTasks() {
this.pageLoading = true;
this.todoState.getTodoCount()
const result = await getTodoTasks({
...this.todoQuery,
SortType: this.sortType
});
if (result.ok) {
this.list = result.val.datas;
this.total = result.val.total;
} else {
if (CustomRespError.checkCode(result.val, 'DATA-000')) {
// 业务处理逻辑,例如弹窗等等
} else {
// 这里使用通用的方法对错误进行统一处理
this.errorState.catchError(result);
}
// 如果您实在是不知道如何处理,请使用 result.unwrap() 将 Checked Exception 转换为 RunTime Exception
}
this.pageLoading = false;
}
}
export default new State();
五、使用 ts-results 的注意事项
1. 它不是万能的
- 它只能处理你提前预定义好的错误类型。
- 对于运行时错误(如数组越界、undefined 属性访问等)仍然需要
try/catch
来兜底。
2. 错误类型尽量继承 Error
- 推荐使用
new Err(new MyError(...))
而不是new Err('string')
。 - 原因是
Error
类型可以被 Sentry 等工具识别,方便日志和上报。
3. 所有 Result 都应该被处理
-
不要"吞掉"错误,即不要写:
tsconst result = doSomething(); // 错误没有处理
-
应该处理掉,或者
unwrap()
抛出错误:tsconst result = doSomething(); if (!result.ok) { result.unwrap(); // 抛出错误,由上层 catch }
六、总结:ts-results 的价值
优势 | 说明 |
---|---|
强制错误处理 | 调用者必须处理所有可能的错误路径 |
类型安全 | TypeScript 支持良好的类型推导 |
结构清晰 | 错误不再是"字符串",而是结构化对象 |
易于调试 | 错误信息可追踪、可上报、可分类 |
最后
ts-results 并不是银弹,但它能帮助我们构建一个更清晰、更健壮的错误处理体系。特别是在大型项目中,它能显著提升代码的可维护性和错误的可追踪性。
如果你的项目中经常出现"错误没处理"、"错误类型混乱"、"错误被吞掉"的问题,不妨试试 ts-results
,它可能是你迈向高质量前端代码的一小步,却是非常关键的一步。