JavaScript 错误处理工程化:从 try-catch 混乱到 Result 模式的类型安全演进
引言:try-catch 的失控困局
在一个中型 JavaScript 项目中翻看代码,你大概率会看到这样的场景:
// 反模式 1:裸 try-catch 吞噬错误
async function fetchUserData(userId: string) {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data;
} catch (e) {
console.error('something went wrong', e);
return null; // 调用方被迫处理 null,错误信息已丢失
}
}
// 反模式 2:嵌套 try-catch 地狱
async function processOrder(orderId: string) {
try {
const order = await getOrder(orderId);
try {
const inventory = await checkInventory(order.sku);
try {
await createShipment(order, inventory);
} catch (e) {
// shipment 失败,但 order 已创建 —— 数据不一致!
console.error(e);
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
}
// 反模式 3:错误类型丢失
interface ApiError {
code: number;
message: string;
}
function isApiError(err: unknown): err is ApiError {
// 运行时猜测,完全不可靠
return typeof err === 'object' && err !== null && 'code' in err;
}
这三个反模式暴露了 JavaScript 错误处理的核心缺陷:原生 try-catch 不携带类型信息,调用方无法在编译期就知道「这个函数可能抛什么错」,只能靠 unknown + 猜 test 守卫在运行时兜底。
核心思想:用 Result 模式将错误提升为一等类型
Result 模式的本质是:函数的返回值不再是一个裸数据或 null,而是一个明确标记了成功/失败状态的容器类型。
// Result 类型定义
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// 构造器
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function Err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
同步场景:消除 null 返回
// ❌ 反模式:调用方不知道 null 代表什么错误
function parseUserInput(input: string): User | null {
try {
return JSON.parse(input) as User;
} catch {
return null; // JSON 解析失败?字段缺失?不知道
}
}
// ✅ Result 模式:错误语义精确
function parseUserInput(input: string): Result<User, ParseError> {
try {
const obj = JSON.parse(input);
if (!isValidUser(obj)) {
return Err(new ParseError('Missing required fields', { received: obj }));
}
return Ok(obj as User);
} catch (e) {
return Err(new ParseError('Invalid JSON', { raw: input }));
}
}
调用方现在有完整的类型提示:Result<User, ParseError> —— 一眼就知道成功时拿到 User,失败时拿到 ParseError,编辑器自动补全,不需要查文档。
异步场景:承诺式错误传播
// ❌ 反模式:Promise 的 reject 类型不可控
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 调用方完全不知道
return res.json();
}
// ✅ Result 模式:异步函数也返回 Result
async function getUser(id: string): Promise<Result<User, FetchError>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return Err(new FetchError(res.status, `Failed to fetch user ${id}`));
}
const data = await res.json();
return Ok(data as User);
} catch (e) {
return Err(new FetchError(0, e instanceof Error ? e.message : 'Network error'));
}
}
// 调用时:类型系统强制你处理两种结果
const result = await getUser('123');
if (result.ok) {
console.log(result.value.name); // ✅ result.value 类型是 User
} else {
console.error(result.error.statusCode); // ✅ result.error 类型是 FetchError
}
关键区别:异步函数不再 throw,而是将错误封装在返回值中。这意味着 TypeScript 可以在编译时检测到你是否遗漏了对错误分支的处理。
进阶:用组合子消除 if/else 瀑布
Result 模式真正的威力在于可组合性。与其每调用一个函数就写一段 if (!result.ok),不如用链式操作:
// map:变换成功值
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? Ok(fn(result.value)) : result;
}
// flatMap / andThen:串联可能失败的调用
function andThen<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
// match:统一处理两个分支并返回值
function match<T, U, E>(
result: Result<T, E>,
onOk: (value: T) => U,
onErr: (error: E) => U
): U {
return result.ok ? onOk(result.value) : onErr(result.error);
}
// ---- 实战:下单流程 ----
interface OrderParams { sku: string; quantity: number }
function validateParams(params: unknown): Result<OrderParams, ValidationError> {
if (!params || typeof params !== 'object') {
return Err(new ValidationError('Invalid params shape'));
}
const p = params as Record<string, unknown>;
if (typeof p.sku !== 'string' || typeof p.quantity !== 'number') {
return Err(new ValidationError('Missing or invalid fields'));
}
return Ok({ sku: p.sku, quantity: p.quantity as number });
}
function reserveInventory(params: OrderParams): Result<string, InventoryError> {
// 模拟库存预留
const reservationId = `rsv-${params.sku}-${Date.now()}`;
return Ok(reservationId);
}
function createOrder(
params: OrderParams,
reservationId: string
): Result<Order, OrderCreationError> {
return Ok({ id: `order-${reservationId}`, ...params, status: 'confirmed' });
}
// 🎯 串联调用:每一步失败时自动短路
function placeOrder(rawInput: unknown): Result<Order, ValidationError | InventoryError | OrderCreationError> {
const paramsResult = validateParams(rawInput);
if (!paramsResult.ok) return paramsResult;
const reserveResult = reserveInventory(paramsResult.value);
if (!reserveResult.ok) return reserveResult;
return createOrder(paramsResult.value, reserveResult.value);
}
// 🎯🎯 用 andThen 消除手动短路
function placeOrderClean(rawInput: unknown): Result<Order, ValidationError | InventoryError | OrderCreationError> {
return andThen(
validateParams(rawInput),
(params) => andThen(
reserveInventory(params),
(reservationId) => createOrder(params, reservationId)
)
);
}
每一个中间步骤的类型都在链条中累积,调用方无需猜测「第几个 try 块对应第几个错误类型」。
边界集成:Result 与传统 try-catch 的桥接
实际工程中,不可能一夜之间把所有代码都迁移到 Result。需要桥接层:
// 将可能 throw 的同步函数包装成 Result
function tryCatch<T, E = Error>(
fn: () => T,
onError?: (error: unknown) => E
): Result<T, E> {
try {
return Ok(fn());
} catch (e) {
const err = onError ? onError(e) : (e as E);
return Err(err);
}
}
// 将可能 reject 的 Promise 包装成 Result
async function tryCatchAsync<T, E = Error>(
promise: Promise<T>,
onError?: (error: unknown) => E
): Promise<Result<T, E>> {
try {
const value = await promise;
return Ok(value);
} catch (e) {
const err = onError ? onError(e) : (e as E);
return Err(err);
}
}
// ---- 桥接示例 ----
// 旧代码:一个会 throw 的第三方库函数
function legacyRiskyCall(): string {
if (Math.random() > 0.5) throw new Error('Boom!');
return 'ok';
}
// 新代码:无缝桥接
const result = tryCatch(
() => legacyRiskyCall(),
(err) => new AppError('Legacy call failed', { cause: err })
);
// 旧 API 调用
const apiResult = await tryCatchAsync(
fetch('/api/data').then(r => r.json()),
(err) => new NetworkError('API unavailable', { cause: err })
);
完整实战:React Hook 中的 Result 集成
import { useState, useEffect, useCallback } from 'react';
type AsyncState<T, E> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E };
function useResult<T, E = Error>(
fetcher: () => Promise<Result<T, E>>,
deps: unknown[] = []
): AsyncState<T, E> & { refetch: () => void } {
const [state, setState] = useState<AsyncState<T, E>>({ status: 'idle' });
const execute = useCallback(async () => {
setState({ status: 'loading' });
const result = await fetcher();
setState(
result.ok
? { status: 'success', data: result.value }
: { status: 'error', error: result.error }
);
}, deps);
useEffect(() => { execute(); }, [execute]);
return { ...state, refetch: execute };
}
// ---- 使用 ----
function UserProfile({ userId }: { userId: string }) {
const { status, data, error, refetch } = useResult(
() => getUser(userId),
[userId]
);
if (status === 'loading') return <Skeleton />;
if (status === 'error') {
return (
<ErrorCard
message={error.message}
code={error.statusCode}
onRetry={refetch}
/>
);
}
if (status === 'success') return <ProfileCard user={data} />;
return null;
}
注意这里的 error 是 FetchError 类型,不是 unknown,所以 error.statusCode 有精确的类型提示,不会出现 any 链式访问。
工程化落地:ESLint 规则 + CI 门禁
// .eslintrc 自定义规则思路
module.exports = {
rules: {
// 禁止裸 try-catch(要求指定错误处理策略)
'no-bare-try-catch': 'warn',
// 要求异步函数显式声明错误类型
'require-error-type-annotation': 'warn',
},
};
配合 CI 门禁,在代码合并前检测裸露的 try-catch 和返回 null 的错误处理模式,逐步推动整个团队向类型安全的错误处理范式迁移。
总结:何时用 Result,何时保留 try-catch
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 业务逻辑函数返回值 | Result | 错误是业务的一部分,调用方需要感知 |
| 第三方库边界 | tryCatch 桥接 | 无法控制外部库的抛出行为 |
| 生命周期级别的致命错误 | throw | 进程无法继续时,让崩溃发生 |
| React 事件处理 | Result | 事件处理器不应让异常逃逸到框架 |
| Node.js Express 中间件 | Result | 统一错误格式,避免 500 堆栈泄漏 |
Result 模式不是要消灭 try-catch,而是让你在需要精确控制错误流程的每一层都拥有编译期安全网。当你的代码库从 catch(e) { return null } 演进到 return Err(new SpecificError(...)) 时,受益的不仅是今天的你,更是三个月后接手这段代码的同事——他看到类型签名就知道一切。
评论区
登录 后参与评论