每次 API 收到请求,你都要验证。每次函数收到来自外部系统的参数,你都要检查。如果手动完成这些工作,单个端点积累的验证代码就可能超过业务逻辑本身。
这是 runtime contracts 的隐性成本。你需要它们,因为 type system 会撒谎:通过 HTTP 传输的 JSON、数据库字段、环境变量在运行时都是以无类型数据的形式到达的。但用纯手工编写的 if 语句来实现它们,会把简单的函数变成一堵难以阅读的防御性代码墙。
有更好的办法。
Runtime contracts 到底是什么
Runtime contract 是函数对其输入和输出做出的保证。传入有效数据,就能得到有效输出。违反 contract,函数会立即失败并给出清晰的错误。
这就是 Design by Contract,自 Eiffel 时代就已存在。这个概念并不新鲜。新鲜的是那些让你无需为每个字段都编写 validator 就能表达 contracts 的工具。
核心洞见是:contracts 是 schemas,而不是 code。Schema 描述的是形状、约束和关系。手动检查这些东西的 code,不过是用错误的语言写成的 schema。
样板代码的陷阱
以下是手动验证在实践中的样子。你有一个处理用户注册请求的函数:
function createUser(payload: unknown) {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload");
}
if (!("email" in payload) || typeof (payload as any).email !== "string") {
throw new Error("email is required and must be a string");
}
if (!(payload as any).email.includes("@")) {
throw new Error("email must be valid");
}
if (!("age" in payload) || typeof (payload as any).age !== "number") {
throw new Error("age is required and must be a number");
}
if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
throw new Error("age must be an integer of at least 13");
}
if (
"tags" in payload &&
(!Array.isArray((payload as any).tags) ||
!(payload as any).tags.every((t: any) => typeof t === "string"))
) {
throw new Error("tags must be an array of strings");
}
// ... finally, the actual work
}
这种做法令人疲惫、容易出错,而且不完整。你检查了 email 是否符合真实的邮箱格式吗?age 是不是 NaN?tags 的元素是否为非空字符串?大概率没有,因为你已经写累了。
而这只是一个函数。把它乘以系统中的每一个 boundary。
声明式 schemas 如何取代样板代码
与其编写 validators,不如描述数据形状,让库来强制执行。
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13),
tags: z.array(z.string().min(1)).optional(),
});
function createUser(payload: unknown) {
const user = UserSchema.parse(payload);
// user is typed as { email: string; age: number; tags?: string[] }
// actual business logic goes here
}
Schema 就是文档。它也是 validator、type definition 和错误信息生成器。在一个地方修改 schema,使用该 contract 的所有地方都会同步更新。
这不是魔法。库在运行时遍历 schema 并应用检查。区别在于,这些检查由小型、可复用的 primitives 组合而成,而不是每次都手写。
Function boundaries 处的 contracts
真正的威力在于将 schemas 直接附加到函数的输入和输出上。你应该让 contract 驻留在 boundary 上,而不是泄漏到实现内部。
function withContract<TInput, TOutput>(
inputSchema: z.ZodSchema<TInput>,
outputSchema: z.ZodSchema<TOutput>,
handler: (input: TInput) => TOutput
) {
return (rawInput: unknown): TOutput => {
const input = inputSchema.parse(rawInput);
const output = handler(input);
return outputSchema.parse(output);
};
}
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const createUser = withContract(
UserSchema,
UserResponseSchema,
(user) => {
// zero validation code here
return db.users.create(user);
}
);
现在函数体中没有任何验证代码。Contract 在边缘被强制执行,而编译器仍然知道内部类型,因为 schema 会推断它们。
问题出在哪里:性能、类型推断和过度验证
声明式 schemas 并非没有代价。在采用之前,你应该了解其中的权衡。
性能是最明显的顾虑。解析复杂的 schema 比几个 if 检查要慢。但在实践中,对于大多数 API 来说,这种差异可以忽略不计。对于简单对象,Zod 大约比手写检查慢 10 倍。对于大多数 HTTP handler 来说,这不过是微秒级的差别。如果你要在热循环中验证数百万条记录,请先做 benchmark。
Type inference 的缺口也可能给你带来麻烦。从 schemas 推断出的类型有时比你手写的更严格或更宽松。你可能需要定义自定义类型,然后让 schema 去匹配它们,而不是反过来。
过度验证是隐形的杀手。并非每个函数都需要 runtime contract。那些在 API boundary 已经过验证的内部函数,不需要再次检查。将 contracts 应用在 system boundaries 上,而不是每个 helper function 上。
如何在不重写 codebase 的前提下落地
你不需要一次性大爆炸式迁移。从 API 的入口点开始。
- 挑选一个对外暴露的函数或 route handler。
- 为它的输入编写一个 schema。
- 用 schema parsing 替代手动验证。
- 让 schema 推断 TypeScript type。删掉你手写的 interface。
先做一个 endpoint,然后是一个 service,再然后是一个 domain。一周之内,你就会发现自己的测试套件在缩水,因为你不再需要测试那些 schema 已经覆盖的验证边界情况。
如果你不使用 TypeScript,这个模式同样适用。Python 有 Pydantic。Go 有 go-playground/validator。Rust 有 validator。原则是一样的:描述 contract,生成检查。
常见问题
Runtime contracts 能取代 unit tests 吗?
不能。它们取代的只是验证输入的那部分 unit tests。你仍然需要测试业务逻辑、错误处理和集成行为。
自定义验证规则怎么办?
每个成熟的 schema 库都支持自定义 refinements。谨慎使用。如果你的自定义规则超过五行,那它大概应该放在函数体里,而不是 schema 中。
输出时也应该验证吗?
是的,对于关键函数。Output contract 能捕捉业务逻辑产生畸形数据的 bug。在源头失败,比让坏数据传播到另一个 service 要便宜得多。
结论
对于接触无类型数据的系统来说,runtime contracts 是不可谈判的。唯一的选择在于,你是用样板代码还是 schema 库来支付这笔费用。
Schema 库只需要一个 dependency 和一点性能开销。而样板代码会消耗你的可维护性、正确性和理智。
选依赖库吧。