《TypeScript编程入门》13.3 API 契约与边界数据校验

本节把校验真正落到工程上:先列出一份完整的边界清单,再逐个给出落地方式——环境变量启动期校验并 fail fast、封装带 schema 的 typedFetch 解析上游响应、用泛型中间件校验 HTTP 请求体、区分 4xx 与 5xx 的错误归属、用 strict 与 passthrough 处理契约演进,最后用契约测试把前后端对齐。读完你能在自己的项目里搭出一套完整的边界防线。

本节目标:读完这一节,你能画出一份完整的「进程边界清单」,并知道每一处该用什么方式校验;能用 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"] } 这样的错误信息直接退出。这里的收益有三个:

  1. env 的类型是从 schema 推导的,env.PORT 是 number,env.NODE_ENV 是字面量联合,编辑器能补全所有合法值;
  2. 拼错变量名会在启动时暴露,而不是在第一次请求时才炸;
  3. 整个项目只在这一个文件里访问 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 模式 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes