
在前后端分离的架构中,错误处理(Error Handling) 往往是耦合最严重、也是最脆弱的环节。
你是否也经历过这样的场景?常见的开发模式是“实现驱动”的:后端先写好代码,抛出一个异常,然后告诉前端:“哎,我这里返了个 500,你处理一下。”前端只能无奈地在代码里打补丁。这种模式下,接口文档永远滞后于代码,前端永远在猜测后端的行为,沟通成本极高。
如果我们反过来,采用规约驱动开发 (Contract-Driven Development) 的思路,局面将完全不同。核心理念很简单:在写任何一行业务逻辑之前,先定义通信的“宪法”。 作为技术社区的实践者,我们可以借鉴像云栈社区这样的平台中分享的工程化思路,将沟通问题转化为技术约束。
一、 立法:定义“单一事实来源”
规约不是 Word 文档,因为文档会过期,且往往没人认真看。规约必须是代码,并且最好是强类型的代码,例如 TypeScript Interfaces。
在 Monorepo 架构中,我们通常需要一个 shared-schema 包,它就是整个系统通信的单一事实来源 (Single Source of Truth)。
1. 错误码的类型化 (The Enum/Union Strategy)
切记,不要使用 string 来随意定义错误码,因为字符串的可能性是无限的,不可控的。正确的做法是使用 Discriminated Unions(辨识联合) 或 Enum,将其封闭为一个有限的集合。
// packages/shared/src/error-contract.ts
/**
* 业务错误码枚举
* 这是一个封闭集合,编译器会确保我们不会拼写错误,
* 也确保了前端 switch-case 能覆盖所有情况。
*/
export enum ErrorCode {
VALIDATION_FAILED = 'VALIDATION_FAILED',
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
PAYMENT_INSUFFICIENT_FUNDS = 'PAYMENT_INSUFFICIENT_FUNDS',
// ...
}
/**
* 严格的错误契约
*/
export interface AppError {
success: false;
code: ErrorCode; // <--- 核心:强制使用枚举
message: string; // 仅供调试的英文描述
traceId: string; // 链路追踪 ID
meta?: Record<string, unknown>; // 结构化元数据
}
这一步看似简单,实则确立了清晰的边界。它意味着后端不能随意创造未声明的错误码,前端也无需再处理未知的错误类型,将猜测变成了确定。
二、 履约:后端不仅仅是抛出异常
在规约驱动的视角下,后端不是在“抛出异常”,而是在履行契约。我们需要确保后端抛出的每一个业务异常,都严格符合 AppError 的定义。
1. 编译时约束
如果后端开发者试图抛出一个 code: “RANDOM_STRING”,理想情况下,TypeScript 编译器应当直接拒绝编译。我们可以通过强制类型继承来实现这一点。
// backend/src/exceptions/business.exception.ts
import { AppError, ErrorCode } from ‘@my-org/shared’;
// 强制继承基类,确保 payload 符合契约
export class BusinessException extends Error {
constructor(
public readonly code: ErrorCode, // 必须是枚举成员
public readonly meta?: Record<string, unknown>
) {
super(code);
}
}
// 使用示例
// ✅ 编译通过
throw new BusinessException(ErrorCode.PAYMENT_INSUFFICIENT_FUNDS, { balance: 0 });
// ❌ 编译失败:Type ‘“NO_MONEY”’ is not assignable to type ‘ErrorCode’.
throw new BusinessException(‘NO_MONEY’);
通过类型系统,我们将“遵守规范”这件事,从依靠开发者的自觉,变成了依靠编译器的强制,从源头杜绝了不一致性。
三、 消费:前端的“穷尽性匹配”
规约驱动给前端带来的最大红利是确定性。前端不再需要防御性地去猜测 res.data?.maybe?.something。既然契约是严格的,前端就可以利用 TypeScript 的 Exhaustiveness Checking(穷尽性检查) 机制,编写更安全、更易维护的代码。
1. 穷尽性检查模式
// frontend/src/utils/error-handler.ts
import { ErrorCode, AppError } from ‘@my-org/shared’;
function ensureExhaustive(value: never): never {
throw new Error(‘未处理的类型:’ + value);
}
export function handleAppError(error: AppError) {
switch (error.code) {
case ErrorCode.VALIDATION_FAILED:
// 处理表单高亮
return showFormErrors(error.meta);
case ErrorCode.PAYMENT_INSUFFICIENT_FUNDS:
// 弹窗引导充值
return showRechargeModal();
case ErrorCode.RESOURCE_NOT_FOUND:
// 跳转 404
return navigate(‘/404’);
default:
// 这里的 default 非常关键
// 如果后端在 ErrorCode 枚举里加了新类型,但前端没处理
// ensureExhaustive 会确保类型检查失败,提醒开发者“你漏了一种情况”
ensureExhaustive(error.code);
}
}
这种模式形成了一个完美的闭环:后端修改契约(添加错误码) -> 前端构建失败(提示未处理)。这彻底杜绝了“后端改了东西,前端在运行时才崩溃”的尴尬情况。
四、 规约的演进 (Evolution)
规约驱动不是指规约一成不变,而是强调规约的修改必须是显式的、版本化的、并经过影响分析的。
- 变更流程:如果后端需要一个新的错误类型,第一步不是直接写后端业务代码,而是去
shared 包修改 ErrorCode 枚举。
- 影响分析:修改完枚举后,运行全量类型检查(Type Check)。编译器会立刻揭示这一改动影响了哪些后端的抛出逻辑,以及哪些前端的消费逻辑。
- 协同开发:前后端开发者可以基于同一个关于类型变更的 Pull Request,同步完成各自逻辑的适配,沟通效率极高。
总结
规约驱动开发 (CDD) 的本质,是把高昂的沟通成本转化为可执行的代码约束。在错误处理这个具体场景下,TypeScript 接口就是我们的宪法。除非修宪,否则任何人都不能越界。
- Before (实现驱动):
- 后端写逻辑 -> 抛出随意的数据 -> (可能忘记)更新文档 -> 前端抓包猜测 -> 编写防御性代码。
- 结果:系统熵增,随着迭代越来越混乱。
- After (规约驱动):
- 定义 Shared Type -> 后端实现被类型约束 -> 前端消费被类型引导 -> 编译器保障全栈一致性。
- 结果:系统熵减,即使规模扩大,秩序依然清晰。
这才是现代全栈工程化,在构建健壮系统时的正确实践路径。它让开发从“事后补救”转向“事先约定”,从根本上提升了协作效率和代码质量。