前端开发··2 阅读·预计 21 分钟

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;
}

注意这里的 errorFetchError 类型,不是 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(...)) 时,受益的不仅是今天的你,更是三个月后接手这段代码的同事——他看到类型签名就知道一切。

0 评论

评论区

登录 后参与评论