Node.js 输入校验与数据契约:Zod、类型安全与工程实践

深入 Node.js 输入校验与数据契约:运行时校验的意义、Zod 核心 API 与 Schema 组合、与 TypeScript 的类型联动(z.infer)、请求校验管道、DB 与 API 边界的数据契约、错误信息国际化,以及 Zod vs Joi vs Valibot 选型对比。

TypeScript 的静态类型只覆盖"编译期",运行时进来的 HTTP 请求、数据库返回、第三方回调全都没有类型保证。Zod 等运行时校验库把"声明即校验、校验即类型"变成可能。本文从为何需要运行时校验讲起,覆盖 Zod 核心用法、与 TS 的类型联动、请求校验管道与数据契约设计。

1. 为什么需要运行时校验

1.1 类型系统的边界

// 编译期:type 只存在编译期,运行时早已擦除
type User = { id: number; name: string };
const u: User = JSON.parse(rawBody); // TS 信了,运行时 rawBody 可能是任意值

JSON.parse 返回 any,TS 无法验证"反序列化出来的一定是 { id: number, name: string }"。边界数据必须运行时校验。

1.2 校验失败的代价

未校验的风险后果
恶意注入字段越权、存储垃圾数据
类型错误(string 当 number)运行时崩溃、SQL 出错
缺失必填字段NPE、未定义行为
错误格式(邮箱/手机号)业务错误、脏数据

一句话:TypeScript 管编译期,Zod 管运行时——凡是从"边界"(HTTP 请求、DB 行、第三方 API、配置文件)进入系统的数据,都要过运行时校验。


2. Zod 核心 API 与 Schema 组合

2.1 基础类型

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['admin', 'user']).default('user'),
});

// 校验
const result = UserSchema.safeParse(raw);
if (result.success) {
  result.data; // 类型已经推导:{ id: number; name: string; ... }
} else {
  result.error.issues; // 结构化错误列表
}

2.2 Schema 组合:复用与细分

const BaseUser = z.object({ id: z.number(), name: z.string() });

const CreateUser = BaseUser.omit({ id });          // 创建时无 id
const UpdateUser = BaseUser.partial();              // 更新时全部可选
const UserView = BaseUser.extend({                  // 扩展字段
  createdAt: z.coerce.date(),
});

// 联合/可空
const Response = z.discriminatedUnion('type', [   // 可辨识联合,比 z.union 更优
  z.object({ type: z.literal('ok'), data: UserView }),
  z.object({ type: z.literal('error'), message: z.string() }),
]);

一句话:Zod 的 schema 是可组合的类型构造器——omit/partial/extend/discriminatedUnion 让你用一份基准声明派生所有变体,杜绝重复定义。


3. 与 TypeScript 的类型联动

3.1 z.infer:从 Schema 推导 TS 类型

// 单一事实来源:schema 即类型
const OrderSchema = z.object({
  id: z.string().uuid(),
  amount: z.number().positive(),
  items: z.array(z.object({ sku: z.string(), qty: z.number().int() })),
});
type Order = z.infer<typeof OrderSchema>;
// Order = { id: string; amount: number; items: { sku: string; qty: number }[] }

3.2 双写 vs 单源

// ✗ 双写:interface 与 zod schema 各写一份,必然漂移
interface Order { ... }
const OrderSchema = z.object({ ... });

// ✓ 单源:只写 schema,类型自动 infer,永不失同步

3.3 输出可再变换

// transform:校验通过后再加工(脱敏、格式化)
const MoneySchema = z.number().transform((v) => Math.round(v * 100));

一句话:z.infer 让"一份 Schema = 一份类型"——校验逻辑和类型定义不双写,重构时不会出现"类型说能传、实际会跑崩"的漂移。


4. 请求校验与错误处理管道

4.1 Express 校验中间件

const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      code: 'VALIDATION_ERROR',
      issues: result.error.issues.map((i) => ({
        path: i.path.join('.'),
        message: i.message,
      })),
    });
  }
  req.validated = result.data; // 后续路由用干净的已校验数据
  next();
};

app.post('/users', validate(CreateUser), (req, res) => {
  // req.validated 类型完整,可直接落库
});

4.2 错误信息结构

{
  "code": "VALIDATION_ERROR",
  "issues": [
    { "path": "email", "message": "Invalid email" },
    { "path": "age", "message": "Number must be greater than 0" }
  ]
}

4.3 边界校验全面覆盖

边界Schema 位置
query 参数入口中间件
path 参数路由参数校验
bodyPOST/PUT 主体
header认证/分页头
配置文件启动时校验并 fail-fast
外部 API 响应反序列化后校验

一句话:所有入口数据都过同一个 validate 中间件,返回统一的 VALIDATION_ERROR 结构;校验失败在入口就拦截,业务代码永远面对"已验证的干净数据"。


5. 数据契约:DB 与 API 边界

5.1 为什么需要数据契约

服务之间、前后端之间、DB 与业务之间如果没有明确的"字段契约",改动一发而牵动全身。用 Zod schema 作为契约文件,是零成本的沟通工具:

共享契约层(共享包 / 模块):
  schemas/
    order.ts        —— Order 的创建/查询/响应 schema
    user.ts         —— User 的公开/内部 schema
    common.ts       —— 分页、错误结构、traceId

5.2 内部模型 vs 对外视图

// 内部:完整字段(含 DB 私有字段)
const OrderInternal = z.object({ id, amount, createdBy, createdAt });

// 对外:白名单视图(不含敏感字段)
const OrderPublic = OrderInternal.omit({ createdBy }).extend({
  createdAt: z.coerce.date(),
});

5.3 与 DB 层的配合

  • ORM 实体用 z.infer 派生,schema 即实体类型;
  • 写库前校验、读库后校验,DB 行进出系统都过一道闸;
  • 契约变更走版本化:v1 schema 保持兼容,新增用 v2。

一句话:Schema 即契约——共享 schema 模块定义"接口长什么样",内部模型与对外视图分开,字段变更时类型错误在编译期就暴露,而非线上崩溃。


6. 错误信息国际化

6.1 自定义错误消息

const schema = z.object({
  email: z.string().email('请输入有效的邮箱地址'),
  age: z.number().min(18, '年龄必须大于等于 18 岁'),
});

6.2 统一映射表

// 前端/后端共享错误字典,避免散落字符串
const messages = {
  'invalid_type': '字段类型不正确',
  'too_small': '字段长度/数值过小',
  'invalid_email': '邮箱格式不正确',
};
// 按 issue.code 翻译

6.3 多语言

生产环境通常英文错误码 + 本地化 message:code 永远稳定(机器读),message 按 Accept-Language 渲染(人读)。

一句话:错误信息 = 稳定 code(机器读)+ 可翻译 message(人读);Zod 的 custom/errorMap 或统一映射表都能做到,别在业务代码里散落中文文案。


7. 选型对比:Zod vs Joi vs Valibot

维度ZodJoiValibot
类型推导z.infer 原生无(需手写 interface)InferOutput 原生
体积中中最小(tree-shaking 友好)
API 风格链式 + 组合链式模块化组合
生态最活跃(tRPC/Server Actions 官方)老牌(Hapi 系)新锐(性能+体积)
学习曲线平缓平缓平缓

选型建议:新项目默认 Zod(生态最广、tRPC/OpenAPI 集成多);对包体积极度敏感的项目考虑 Valibot;老项目已有 Joi 不必迁移。

一句话:现代 Node/TS 项目默认 Zod——类型联动、生态、社区三方面都最稳;体积敏感的选 Valibot,老项目有 Joi 别硬迁。


8. 踩坑清单

坑现象对策
只信 TS 类型运行时收到脏数据边界全部过 Zod
schema 与 interface 双写类型漂移只写 schema + z.infer
校验错误直接 500前端拿不到字段问题统一 400 VALIDATION_ERROR
大 schema 全放 controller难复用抽共享契约层
不校验外部 API 响应第三方字段变化崩溃反序列化后校验
只校验 body 不校验 query参数注入全边界覆盖
忽略 transform数据格式不统一校验后 transform 脱敏/归一
错误文案散落无法国际化统一 code + message 映射

9. 总结

环节要点
必要性TS 管编译期,Zod 管运行时
核心 APIobject + 组合(omit/partial/extend/union)
类型联动z.infer 单源,不双写
请求校验入口 validate 中间件统一收口
数据契约Schema 即契约,内外部视图分离
错误信息稳定 code + 可翻译 message
选型新项目 Zod,体积敏感 Valibot

一句话记住:运行时校验是"编译期类型"的补完——一份 Zod Schema 同时定义类型、校验、契约与错误结构,入口拦截脏数据,出口保证字段安全,数据不再有"我以为它是 string 结果它是 undefined"的惊喜。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「nodejs」更多文章

  1. Node.js CLI 工具开发实战:参数、交互、打包与发布
  2. Node.js 错误处理与日志工程:从异常到可观测
  3. Node.js 缓存架构实战:内存、Redis 与一致性策略