本节目标:读完这一节,你能画出一份完整的「进程边界清单」,并知道每一处该用什么方式校验;能用 Zod 在启动期校验环境变量并 fail fast;能封装一个由 schema 驱动返回类型的
typedFetch;能写出泛型校验中间件处理 HTTP 请求体;能正确区分「客户端错误」与「上游契约漂移」并给出不同的告警策略;能处理契约演进中的未知字段与新增字段;并知道契约测试要测什么。
13.3 API 契约与边界数据校验
13.1 讲清了「哪里会出事」,13.2 给了「用什么工具」。这一节解决最后一个问题:把校验放在哪里,怎么放得不出错。
先说一个容易混淆的概念。很多团队把「类型定义」当成契约,这是不完整的:
- 类型定义描述「形状」;
- 契约描述「形状 + 约束 + 演进规则」——哪些字段必填、字段的取值范围、版本升级时旧客户端还能不能跑。
类型在编译期就消失了,契约必须在运行时被检验。所以契约的载体是 schema,而不是 interface。
一份完整的边界清单
一个 Node.js 服务真正需要设防的入口,比大多数人想象的多:
| 边界 | 进入方式 | 校验时机 | 失败后果 |
|---|---|---|---|
| 环境变量 | process.env | 进程启动 | 启动失败,进程退出 |
| 配置文件 | readFileSync + JSON.parse | 进程启动 | 启动失败,进程退出 |
| HTTP 请求体 | 框架的 req.body | 每个请求 | 返回 400 |
| 查询 / 路径参数 | req.query / req.params | 每个请求 | 返回 400 |
| 上游 HTTP 响应 | fetch / axios | 每次调用后 | 抛错、降级或告警 |
| 消息队列消息 | 消费回调 | 每条消息 | 拒绝或进死信队列 |
| 数据库返回值 | ORM 查询结果 | —— | 一般信任 ORM 类型 |
localStorage / Cookie | 浏览器读取 | 每次读取 | 清空并回退默认值 |
| 第三方 Webhook | 请求体 + 签名 | 每个回调 | 返回 401 或 400 |
一条经验法则:只要数据跨过了一个你不控制的可信边界,就必须校验。同进程内、同模块内的函数调用不需要。
第一道防线:环境变量与启动期校验
环境变量是最容易被忽视的边界。它由部署环境提供,类型是 string | undefined,但代码里到处都在当字符串用:
// ❌ 拼错变量名时静默变成 NaN
const port = process.env.PORT; // string | undefined
const server = app.listen(Number(port));
更好的做法是在进程启动时一次性校验,失败就立刻退出——fail fast 比带着错配置跑起来强得多:
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("环境变量校验失败:");
console.error(JSON.stringify(parsed.error.flatten().fieldErrors, null, 2));
process.exit(1);
}
export const env = parsed.data;
如果部署时漏配了 DATABASE_URL,进程会带着 { "DATABASE_URL": ["Required"] } 这样的错误信息直接退出。这里的收益有三个:
env的类型是从 schema 推导的,env.PORT是number,env.NODE_ENV是字面量联合,编辑器能补全所有合法值;- 拼错变量名会在启动时暴露,而不是在第一次请求时才炸;
- 整个项目只在这一个文件里访问
process.env,其余模块统一import { env },便于审计。
注意 .optional() 与 .default() 的取舍:必须有值的配置用必填(缺失即退出),有合理默认值的用 default,真的可以不存在的才用 optional。把 DATABASE_URL 写成 optional() 等于把崩溃推迟到了第一次查询。配置文件同理,把 JSON.parse 的结果显式标注为 unknown 再交给 schema,这个标注本身就在提醒读代码的人「这里开始是不可信数据」。
第二道防线:封装一个 schema 驱动的 typedFetch
上游响应是最容易出错的地方。13.1 里那个 createdAt: Date 的坑,根源就是「类型是手写的,数据是别处给的」。
解法是把 schema 作为必填参数,让返回类型由 schema 推导:
import { z } from "zod";
export class UpstreamContractError extends Error {
constructor(
readonly url: string,
readonly issues: z.ZodIssue[],
) {
super(`上游响应不符合契约:${url}`);
this.name = "UpstreamContractError";
}
}
export async function typedFetch<S extends z.ZodTypeAny>(
url: string,
schema: S,
init?: RequestInit,
): Promise<z.output<S>> {
const res = await fetch(url, init);
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} - ${url}`);
const text = await res.text();
let json: unknown;
try {
json = JSON.parse(text);
} catch {
throw new Error(`响应不是合法 JSON(前 100 字符):${text.slice(0, 100)}`);
}
const result = schema.safeParse(json);
if (!result.success) {
throw new UpstreamContractError(url, result.error.issues);
}
return result.data;
}
关键在于 S extends z.ZodTypeAny 与返回类型 z.output<S>:调用方不需要写任何类型标注,类型会自动跟着 schema 走——这正是 8.2 泛型默认值与类型推断
讲的「让类型从实参生长出来」。
使用起来是这样:
const OrderSchema = z.object({
id: z.number().int(),
amount: z.number().nonnegative(),
createdAt: z.string().datetime().transform((s) => new Date(s)),
status: z.enum(["pending", "paid", "shipped", "cancelled"]),
});
const order = await typedFetch("https://api.example.com/orders/1", OrderSchema);
console.log(order.amount.toFixed(2)); // number,无需断言
console.log(order.createdAt.getFullYear()); // Date,已经不是字符串了
order 的类型是 z.output<typeof OrderSchema>,也就是把 createdAt 从字符串变成了 Date。13.1 里那类「JSON 里是字符串、类型写成 Date」的 bug,在结构上被消灭了。
配套的注意点:不要对同一个响应解析两次(解析一次,之后全程用可信类型传递);不要在 typedFetch 里吞掉错误(上游契约漂移是需要告警的事件);超时与重试属于网络层,不要塞进 schema 逻辑,可参考 /nodejs-http-client-undici/
里的实践。
第三道防线:服务端请求体校验中间件
服务端这一侧,请求体来自客户端,同样是不可信数据。用泛型中间件把校验前置:
import type { RequestHandler } from "express";
import { z } from "zod";
export function validateBody<S extends z.ZodTypeAny>(schema: S): RequestHandler {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "INVALID_BODY",
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
});
return;
}
// 用解析后的数据覆盖原始 body:未知字段已被 strip,
// 类型与数据从这一刻起是一致的
req.body = result.data;
next();
};
}
挂到路由上:
const CreateUserSchema = z.object({
name: z.string().min(1).max(50),
email: z.string().email(),
age: z.coerce.number().int().min(0).max(150).optional(),
});
app.post("/users", validateBody(CreateUserSchema), async (req, res) => {
// req.body 在框架类型里是 any,但经过中间件后已经是可信数据。
const payload = req.body as z.output<typeof CreateUserSchema>;
const user = await userService.create(payload);
res.status(201).json(user);
});
有一个类型与运行时的接缝必须承认:Express 把 req.body 声明为 any,编译器无法知道中间件已经改写了它。所以这里需要一次断言。区别在于——这是一次「已被运行时验证过」的断言,而不是 13.1 里那种「凭感觉的承诺」。把这类断言集中放在中间件边界,是可控的代价。如果想让类型更严丝合缝,可以给 Request 做模块扩充,或者换用类型友好的框架(如 /typescript-hono-edge-framework/
),思路一致:在框架的类型接缝处做一次收口。
查询参数与路径参数同理:用 z.coerce.number() 处理「永远是字符串」的输入(?page=2 进来是 "2"),再用 .default() 补上缺省值。
错误归属:400 还是 500
这是本节最重要的一条工程判断。校验失败时要先问一句:是谁的错?
| 失败位置 | 责任方 | HTTP 状态 | 是否需要告警 |
|---|---|---|---|
| 请求体不符合 schema | 调用方 | 400 | 否(正常业务流量) |
| 路径 / 查询参数非法 | 调用方 | 400 | 否 |
| 鉴权失败 | 调用方 | 401 / 403 | 否 |
| 上游响应不符合契约 | 上游服务 | 502 | 是 |
| 环境变量缺失 | 部署配置 | 启动即退出 | 是 |
| 消息队列消息不符合契约 | 生产方 | 进死信队列 | 是 |
把上游契约漂移当成 400 返回给用户,是最常见的误判——用户会以为自己传错了参数,而实际上是你依赖的服务改了接口。正确的做法是:
app.get("/orders/:id", async (req, res) => {
try {
const order = await typedFetch(orderUrl(req.params.id), OrderSchema);
res.json(order);
} catch (err) {
if (err instanceof UpstreamContractError) {
logger.error("上游契约漂移", { url: err.url, issues: err.issues });
metrics.increment("upstream.contract.mismatch");
res.status(502).json({ error: "UPSTREAM_CONTRACT_ERROR" });
return;
}
throw err;
}
});
注意日志里不要记录原始响应体——里面可能有用户的个人信息。记录 url 与 issues 的 path、code 就足够定位问题了。错误分类与结构化日志的完整做法,可以延伸阅读 /typescript-error-handling-result/
与 /nodejs-error-handling-logging/
。
契约演进:未知字段与新增字段
接口是会变的。契约设计的核心问题是:上游加了一个字段,你的服务会不会挂?
Zod 默认 strip,所以上游新增字段不会导致解析失败——这是好消息,也是默认值选得对的原因。但有两种情况需要主动处理。
情况一:上游把必填字段改成了可选或删除。 这会直接导致校验失败。应对方式是把非核心字段降级为可选,并提供兜底值:
const ProductSchema = z.object({
id: z.number().int(),
name: z.string(),
price: z.number().nonnegative(),
currency: z.string().default("CNY"), // 上游新加、暂时可能缺失
description: z.string().optional(), // 纯展示字段:允许缺失
});
判断标准是这个字段缺失时业务能不能继续。不能继续的必须必填(宁可 502 也不要写出脏数据),能继续的用 default 或 optional。
情况二:需要区分「容忍」与「严格」。 同一个 schema,在不同场景下用不同策略:
const base = z.object({ id: z.number().int(), name: z.string() });
export const ProductSchema = base.passthrough(); // 运行时解析上游响应:容忍未知字段
export const ProductSchemaStrict = base.strict(); // 契约测试:未知字段即错误
这是一个很实用的组合:生产环境宽、测试环境严。生产环境要的是可用性,测试环境要的是尽早暴露问题。
情况三:接口要同时服务新旧客户端。 常见做法是写一个兼容层:用 z.union 同时接受旧形状(下划线字段名 created_at)与新形状(驼峰字段名 createdAt),再把旧形状 transform 成新形状统一输出。兼容层的原则是只在新旧形状之间做转换,不夹带业务逻辑。API 版本化的整体策略可以参考 /api-versioning-strategies-best-practices/
与 /api-contract-governance/
。
契约的来源:schema 优先还是类型优先
校验器写好了,接下来是「谁来定义契约」的问题。两种路线:
| 路线 | 流程 | 优点 | 缺点 |
|---|---|---|---|
| schema 优先 | Zod → 生成 JSON Schema / OpenAPI | 单一来源,运行时校验直接复用 | 需要生成与发布流程 |
| 类型优先 | TS 类型 → 生成 JSON Schema → 生成校验器 | 前端团队熟悉 | 类型信息有限,无法表达 .min() 等约束 |
对多数 TypeScript 项目,schema 优先是最省心的:Zod schema 既是运行时校验器,又能通过工具导出 JSON Schema 给文档与其他语言使用。类型的表达能力(.min()、.email()、transform)在这里第一次变成了「可执行的文档」。
更进一步的类型安全做法是把契约放进共享包,前后端同时引用,这就是 17.2 前后端共享类型与 API 契约 的主题;如果服务端也由 TypeScript 编写,端到端的方案可以看 /nodejs-trpc-typesafe-api-guide/ 与 /typescript-api-type-generation/ 。
契约测试:把对齐变成 CI 门禁
运行时校验解决的是「不崩」,契约测试解决的是「不悄悄错」。区别在于:上游把 amount 从「元」改成「分」,schema 完全能通过(都是正数),但业务已经错了。
契约测试的做法是:在 CI 里用严格模式跑一遍真实的(或录制的)响应:
import { expect, it } from "vitest";
it("GET /orders/:id 的响应符合契约", async () => {
const res = await fetch(`${BASE_URL}/orders/1`);
const json = (await res.json()) as unknown;
const result = OrderSchema.strict().safeParse(json);
if (!result.success) {
console.error(result.error.flatten().fieldErrors); // 让失败信息直接可读
}
expect(result.success).toBe(true);
});
两个要点:用 .strict() 抓住「文档说没有、实际却有」的字段;断言必填字段存在而不是「字段数量完全一致」,否则上游加字段就会红。契约测试要定期在 CI 中跑,而不是只在本地跑一次——上游服务不是你能控制的,契约漂移只会在某天突然发生。更多组织方式可参考 /contract-testing/
。
常见坑
坑一:只在开发环境开校验。
if (process.env.NODE_ENV !== "production") {
UserSchema.parse(data); // ❌ 生产环境恰恰最需要校验
}
校验的目的是保护生产环境,关掉它等于把防线拆在最需要的地方。如果担心性能,应该优化 schema 本身,而不是关掉校验。
坑二:校验完又用 as 绕回去。
const parsed = UserSchema.safeParse(data);
const user = data as User; // ❌ parsed 白算了
校验后必须使用 parsed.data,不能继续用原始对象。
坑三:解析一次就永久缓存。 如果上游数据结构变了,缓存不会告诉你。要么给缓存设过期时间,要么把「缓存里的数据」也当作一个边界,读取时重新校验。
坑四:把 SyntaxError 当成业务错误。 Unexpected token < in JSON at position 0 意味着拿到的是 HTML。这类错误应当单独识别并给出可读信息,而不是笼统地报「请求失败」。前面 typedFetch 里那段 try/catch 加 text.slice(0, 100) 就是为此准备的。
坑五:客户端与服务端各写一份 schema。 两份规则迟早漂移。把它们放进共享包,是 17.2 前后端共享类型与 API 契约 要解决的问题。
三层防线总览
把本章的内容收成一张表:
| 防线 | 手段 | 拦住的错误 | 拦不住的错误 |
|---|---|---|---|
| 编译期 | TypeScript 类型 | 拼写错误、字段缺失、类型不符 | 外部数据的真实形状 |
| 运行时边界 | Zod schema | 形状不符、范围越界、格式错误 | 语义错误(单位变化、业务规则) |
| 契约测试 | CI 中的严格校验 | 契约漂移、文档过期 | 运行期的偶发异常 |
三层缺一不可:只有类型会在运行时崩,只有校验会漏掉编译期就能抓的错,只有契约测试会等到发版才发现问题。延伸阅读:本章涉及的整体工程实践,可参考既有专题 /typescript-runtime-validation-typesafe/ 与 /typescript-engineering-advanced/ ;表单场景可看 /frontend-forms-validation-architecture/ 。
小结
- 契约 = 形状 + 约束 + 演进规则,载体是 schema 而不是
interface;类型在编译期消失,契约必须在运行时被检验。 - 边界清单包括环境变量、配置文件、CLI 参数、HTTP 请求与响应、消息队列、Webhook、浏览器存储;同进程内部的函数调用不需要校验。
- 环境变量在启动期用 schema 校验并 fail fast,导出推导类型,全项目只在这一处访问
process.env。 typedFetch<S extends z.ZodTypeAny>(url, schema)让返回类型由 schema 推导,transform顺手完成字符串到Date之类的转换,从结构上消灭「类型是 Date、数据是字符串」的坑。- 服务端用泛型中间件校验请求体,校验后以解析结果覆盖原始数据;框架类型接缝处允许一次集中断言,但必须建立在运行时已验证的前提上。
- 错误归属要分清:请求体非法是 400(调用方的问题),上游契约漂移是 502 并需要告警;日志只记
path与code,不记原始数据。 - 契约演进策略:缺了业务就不能跑的字段保持必填,其余用
default/optional;生产环境passthrough容忍未知字段,测试环境strict抓错。 - 契约测试在 CI 里用严格模式跑真实响应,抓的是运行时校验抓不到的语义漂移。
到这里,第 13 章的三节完成了一个闭环:13.1 说明类型为什么在运行时靠不住,13.2 给出「一份定义、两处生效」的工具,13.3 把工具装到进程的每一个入口上。第 14 章我们将顺着本节反复出现的 throw 与 catch 往下走,系统地讨论异步世界里的错误处理——Result 模式、Promise 与 async/await 的类型,以及并发控制、取消与超时。
阅读导航:上一节:13.2 Zod 模式验证与类型推导 · 下一节:14.1 错误类型与 Result 模式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。