一、为什么需要类型安全的异常处理

在JavaScript的世界里,我们经常用try-catch来捕获异常,但这种方式有个很大的问题:它完全不关心错误的类型。你可能会在运行时才发现,原本以为是个网络错误,结果却是个类型错误。TypeScript作为JS的超集,给我们提供了类型系统,但默认的错误处理机制还是沿用JS那套,这就有点"穿着西装搬砖"的感觉。

来看个典型的JS风格错误处理:

function fetchUser(id) {
  try {
    // 这里可能抛出多种错误:网络错误、JSON解析错误、数据格式错误...
    const response = await fetch(`/api/users/${id}`);
    return await response.json();
  } catch (error) {
    // error的类型是any,我们完全不知道可能是什么
    console.log("出错了,但不知道具体是什么错", error);
  }
}

TypeScript可以做得更好。通过类型守卫和自定义错误类型,我们能创建一套类型安全的错误处理体系,让编译器也能帮我们检查错误处理是否完备。

二、构建类型化的错误体系

2.1 定义错误类型层次结构

首先我们需要建立一个错误类型体系。这就像给错误分门别类,让每种错误都有自己的"身份证"。

// 基础错误类型
class AppError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "AppError";
  }
}

// 网络相关错误
class NetworkError extends AppError {
  constructor(public statusCode: number, message: string) {
    super(message);
    this.name = "NetworkError";
  }
}

// 业务逻辑错误
class BusinessError extends AppError {
  constructor(public code: string, message: string) {
    super(message);
    this.name = "BusinessError";
  }
}

// 数据验证错误
class ValidationError extends AppError {
  constructor(public field: string, message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

2.2 类型守卫函数

有了错误类型,我们还需要一些"哨兵"来帮我们识别错误类型:

// 类型守卫函数
function isNetworkError(error: unknown): error is NetworkError {
  return error instanceof NetworkError;
}

function isBusinessError(error: unknown): error is BusinessError {
  return error instanceof BusinessError;
}

function isValidationError(error: unknown): error is ValidationError {
  return error instanceof ValidationError;
}

这些函数不仅能在运行时检查类型,还能让TypeScript在编译时就知道具体的错误类型。

三、实战:类型安全的错误处理模式

3.1 模式一:明确错误返回类型

我们可以让函数明确返回可能发生的错误类型:

async function fetchUserProfile(
  userId: string
): Promise<UserProfile | NetworkError | BusinessError> {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      // 明确返回NetworkError
      return new NetworkError(response.status, "请求用户数据失败");
    }
    
    const data = await response.json();
    
    if (!validateUserProfile(data)) {
      // 明确返回BusinessError
      return new BusinessError("INVALID_PROFILE", "用户数据格式不正确");
    }
    
    return data as UserProfile;
  } catch (error) {
    // 捕获未预期的错误,转换为已知错误类型
    if (error instanceof Error) {
      return new NetworkError(500, error.message);
    }
    return new NetworkError(500, "未知错误");
  }
}

使用时可以这样处理:

const result = await fetchUserProfile("123");
if (result instanceof NetworkError) {
  // 现在result被识别为NetworkError
  console.error(`网络错误: ${result.statusCode}`);
} else if (result instanceof BusinessError) {
  // 现在result被识别为BusinessError
  console.error(`业务错误: ${result.code}`);
} else {
  // 这里result一定是UserProfile
  displayUserProfile(result);
}

3.2 模式二:使用Either模式

函数式编程中的Either模式也很适合处理这种情况:

type Either<L, R> = { kind: "left"; value: L } | { kind: "right"; value: R };

async function fetchOrder(
  orderId: string
): Promise<Either<AppError, Order>> {
  try {
    const response = await fetch(`/api/orders/${orderId}`);
    
    if (!response.ok) {
      return {
        kind: "left",
        value: new NetworkError(response.status, "订单请求失败")
      };
    }
    
    const data = await response.json();
    return { kind: "right", value: data as Order };
  } catch (error) {
    return {
      kind: "left",
      value: new NetworkError(500, error instanceof Error ? error.message : "未知错误")
    };
  }
}

使用Either模式时,处理方式更加结构化:

const result = await fetchOrder("456");
if (result.kind === "left") {
  // 错误处理
  console.error(result.value.message);
} else {
  // 成功处理
  processOrder(result.value);
}

四、高级模式与最佳实践

4.1 错误转换与封装

在实际应用中,我们经常需要将底层错误转换为高层错误:

class UserService {
  async getUser(id: string): Promise<User | AppError> {
    try {
      const response = await this.apiClient.get(`/users/${id}`);
      
      // 将API特定的错误转换为业务错误
      if (response.error) {
        return this.transformApiError(response.error);
      }
      
      return response.data;
    } catch (error) {
      // 封装所有未处理错误
      return new AppError("USER_SERVICE_ERROR", "获取用户失败");
    }
  }
  
  private transformApiError(apiError: ApiError): AppError {
    switch (apiError.code) {
      case "USER_NOT_FOUND":
        return new BusinessError("USER_NOT_FOUND", "用户不存在");
      case "INVALID_INPUT":
        return new ValidationError(apiError.field, apiError.message);
      default:
        return new NetworkError(500, apiError.message);
    }
  }
}

4.2 异步错误收集

在处理多个并行操作时,我们可以收集所有错误:

async function fetchMultipleResources() {
  const [usersResult, productsResult] = await Promise.all([
    fetchUsers().catch(e => e),
    fetchProducts().catch(e => e)
  ]);
  
  const errors: AppError[] = [];
  
  if (usersResult instanceof AppError) errors.push(usersResult);
  if (productsResult instanceof AppError) errors.push(productsResult);
  
  if (errors.length > 0) {
    // 处理收集到的所有错误
    throw new AggregateError(errors, "多个资源加载失败");
  }
  
  return {
    users: usersResult as User[],
    products: productsResult as Product[]
  };
}

五、应用场景与技术考量

5.1 适用场景

这种模式特别适合:

  • 大型TypeScript项目,需要严格类型安全
  • 需要明确区分业务错误和技术错误的系统
  • 前后端协作项目,需要统一错误处理规范
  • 需要详细错误日志和监控的应用

5.2 技术优缺点

优点:

  1. 编译时就能发现错误处理遗漏
  2. 代码自文档化,从类型就知道可能发生的错误
  3. 错误处理逻辑更加结构化
  4. 便于统一错误报告和监控
  5. 与TypeScript类型系统完美集成

缺点:

  1. 需要前期设计错误类型体系
  2. 对小型项目可能显得过于复杂
  3. 需要团队成员都遵守这套规范
  4. 某些情况下会增加一些样板代码

5.3 注意事项

  1. 不要过度设计,根据项目规模选择合适的粒度
  2. 保持错误类型的稳定,避免频繁修改
  3. 为常见错误类型编写工具函数和类型守卫
  4. 考虑错误国际化需求
  5. 记录足够的错误上下文信息
  6. 注意错误对象的序列化问题

六、总结

TypeScript的类型系统为我们提供了重新思考错误处理的机会。通过建立类型化的错误体系,我们能让错误处理更加可靠和可维护。虽然需要一些前期投入,但在复杂项目中,这种投入很快就会通过减少运行时错误和提高开发效率得到回报。

记住,好的错误处理不是事后添加的,而应该是一开始就设计好的。类型安全的错误处理不仅能捕获更多错误,还能让你的代码更加自描述,让团队成员更容易理解各种可能的执行路径。