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 参数 | 路由参数校验 |
| body | POST/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 行进出系统都过一道闸;
- 契约变更走版本化:
v1schema 保持兼容,新增用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
| 维度 | Zod | Joi | Valibot |
|---|---|---|---|
| 类型推导 | 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 管运行时 |
| 核心 API | object + 组合(omit/partial/extend/union) |
| 类型联动 | z.infer 单源,不双写 |
| 请求校验 | 入口 validate 中间件统一收口 |
| 数据契约 | Schema 即契约,内外部视图分离 |
| 错误信息 | 稳定 code + 可翻译 message |
| 选型 | 新项目 Zod,体积敏感 Valibot |
一句话记住:运行时校验是"编译期类型"的补完——一份 Zod Schema 同时定义类型、校验、契约与错误结构,入口拦截脏数据,出口保证字段安全,数据不再有"我以为它是 string 结果它是 undefined"的惊喜。
延伸阅读
- Node.js TypeScript 工程化实践 — TS 类型系统与工程化
- Node.js 数据库集成:ORM 与事务 — 校验与 ORM 实体配合
- Node.js NestJS 全栈指南 — DTO 与校验管道(ValidationPipe)
- Node.js GraphQL API 设计 — Schema 层的数据契约对比
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。