Zod与express-validator:两种Express验证方案
对比了基于模式的第一请求验证方式与Zod,以及基于链式的express-validator中间件,在设置、错误格式化及常见问题方面进行了阐述。
处理不可信的输入是任何 Express API 首要解决的难题之一,实现这一目标的方法不止一种——既有基于模式定义的库,也有更偏向流程化、链式结构的验证工具。本文将探讨这两种方法,首先介绍基于 Zod 的模式驱动型方案。
使用 Zod 验证请求
Express 本身不会对传入的数据进行任何验证。如果没有在边界处进行检查,路由处理程序会直接收到原始的 req.body、req.query 和 req.params——其中数值字段可能是字符串,某些字段可能完全缺失,而那些结构异常的请求数据在进入业务逻辑层后才会引发问题。
Zod通过让开发者以TypeScript优先的架构来描述期望的数据结构来解决这一问题。你只需定义一次架构,再用z.infer从中生成静态类型,然后在HTTP层边缘解析传入的数据,这样下游的所有处理环节都能收到有效数据。任何未通过验证的内容都将在处理代码执行之前就转化为HTTP 400响应。
以下示例使用了Zod 4(z.email()、z.uuid()、z.coerce),以及Express验证中间件、一个通用的错误格式化辅助工具,还列出了常见陷阱。
前置条件
你需要 Node.js 26 版本、Zod 4(npm i zod),以及带有类型定义的 Express(npm i express 和 npm i -D @types/express)。像 z.string().email() 这样的旧版 Zod 3 链式语法在 v4 中仍然可用,但已过时——建议使用下面所示的 newer 顶层函数。
声明模式
// schemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParamSchema = z.object({
id: z.uuid()
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().min(1).optional()
});
z.coerce.number() 对查询字符串值非常有用,因为从 HTTP 查询中读取的任何内容无论其逻辑类型如何都会以字符串形式出现。在边界处建议使用 safeParse 而非 parse,这样就能更好地控制最终的 HTTP 状态码和响应体。
统一格式化错误信息
将 ZodError.issues 转换为统一的稳定 JSON 结构,而非在每个路由中单独处理格式错误。Zod 4 还提供了 z.flattenError() 用于生成扁平的、以字段名为键的错误映射,以及 z.treeifyError() 用于生成与模式结构一致的嵌套错误格式。
// format-zod-error.ts
import { ZodError } from 'zod';
export function formatZodError(error: ZodError) {
return {
message: 'Validation failed',
issues: error.issues.map((issue) => ({
path: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code
}))
};
}
验证中间件
在路由处理程序执行之前先对 body、query 和 params 进行验证,然后将解析后的值写回,这样处理程序就能收到已类型化且转换过的数据。
// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';
type RequestSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};
export function validate(schemas: RequestSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const parseOrReject = (schema: ZodType, value: unknown) => {
const parsed = schema.safeParse(value);
if (!parsed.success) {
res.status(400).json(formatZodError(parsed.error));
return null;
}
return parsed.data;
};
if (schemas.body) {
const body = parseOrReject(schemas.body, req.body);
if (body === null) return;
req.body = body;
}
if (schemas.query) {
const query = parseOrReject(schemas.query, req.query);
if (query === null) return;
res.locals.query = query;
}
if (schemas.params) {
const params = parseOrReject(schemas.params, req.params);
if (params === null) return;
res.locals.params = params;
}
next();
};
}
可按如下方式在每个路由中配置它:
app.post('/users', validate({ body: createUserSchema }), (req, res) => {
// req.body is CreateUserInput
res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});
app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
const { limit, q } = res.locals.query;
// ...
});
app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
const { id } = res.locals.params;
// ...
});
查询结果和参数结果存储在 res.locals 中,因为 Express 的类型系统将 req.query/req.params 视为普通的字符串映射;直接替换它们会与这种类型定义产生冲突。
潜在问题
- 查询字符串始终为字符串类型——对于数字和布尔值,应使用
z.coerce(或z.string()结合转换函数)进行处理。 parse方法会直接抛出ZodError异常;你可以自行捕获该异常并将其转换为400错误响应,或者改用safeParse方法。- Zod对象的模式默认会移除未知键;若要拒绝这些未知键,需添加
.strict()选项。 - 像
CreateUserInput这样的推断类型仅存在于编译时——在数据边界处也务必进行解析。 - 在Zod 4中,
z.uuid()会依据更严格的新UUID规范进行验证;如果你只需要由八位、四位、四位、四位及十二位十六进制数字组成的通用格式,且不要求遵循更严格的规则,可使用z.guid()代替。
另一种方案:使用express-validator实现基于中间件的验证
Zod 并非防止不良输入进入处理函数的唯一方法。Express 应用长期以来一直依赖 express-validator,这是一个专为 Express 设计的中间件库,它采用不同的方式来解决同样的问题。
想象这样一个注册请求:
{
"email": "hello",
"password": "123"
}
如果控制器直接检查这个请求体,每个字段都需要单独进行手动验证,很快就会变成一堆将验证与业务逻辑混在一起的条件语句:
if (!email) ...
if (!email.includes("@")) ...
if (!password) ...
if (password.length < 8) ...
express-validator 将这些逻辑从控制器中移出,放到专门的中间件步骤中,这样请求在到达处理函数之前就会先经过验证:
Request
↓
Validation
↓
Controller
↓
Business Logic
这种分离正是该库的核心理念:让控制器能够专注于完成它应有的任务。
要开始使用,先安装该包:
npm install express-validator
导入 body 辅助函数,然后为每个需要验证的字段构建相应的验证链:
import { body } from "express-validator";
export const registerValidator = [
body("email")
.isEmail()
.withMessage("Invalid email"), body("password")
.isLength({ min: 8 })
.withMessage("Password must contain at least 8 characters"), body("username")
.notEmpty()
.withMessage("Username is required"),
];
将该中间件附加到路由上,置于控制器之前:
router.post(
"/register",
registerValidator,
registerController
);
仅定义验证规则是不够的——还需要读取在验证过程中收集到的所有错误信息:
import { validationResult } from "express-validator";
const errors = validationResult(req);if (!errors.isEmpty()) {
return res.status(400).json({
errors: errors.array(),
});
}
有了这样的验证机制,无效的请求数据会在任何业务逻辑执行之前被以 400 错误码拒绝。
内置的验证器能够很好地处理常见情况:
.isEmail()
.isLength()
.notEmpty()
.isInt()
但实际应用中往往需要一些库无法预先知晓的规则——例如在注册时可能需要检查邮箱是否已被占用。这就是 .custom() 的作用:
body("email")
.isEmail()
.bail()
.custom(async (email) => {
const user = await User.findOne({ email });
if (user) {
throw new Error("Email already registered");
} return true;
});
自定义验证器可以是异步的,这使它们适用于数据库查询以及那些依赖特定业务逻辑的检查。请注意,在自定义验证之前调用的 .bail() 方法——如果邮箱已经未能通过 .isEmail() 验证,该方法就会跳过后续的所有处理步骤,包括异步查询,从而避免不必要的数据库往返操作。
在这两个库之间进行选择——或者选择另一个成熟的选项 Joi——取决于哪种更适合你的技术栈:express-validator 适用于已经基于 Express 中间件构建的项目,Zod 则适合以 TypeScript 为主、采用模式驱动的代码库,而 Joi 是一种成熟且通用的替代方案。并没有绝对正确的选择,这取决于应用程序的架构设计。
无论您选择哪种工具,express-validator的优势在于其内置的验证器、数据净化辅助功能、自定义及异步验证器、中间件模型以及集中式的错误处理机制。一个规范的 Express 请求处理流程通常如下所示:
Request
↓
Validator
↓
Controller
↓
Service
↓
Database
关键不在于仅仅确认字符串是否类似电子邮件地址——而是要尽可能早地拒绝无效输入,从而保证整个应用程序的整洁性。
相关阅读
- 2026年的TC39提案:装饰器、Temporal与Signals详解 ——深入探讨三项TC39提案——原生装饰器、Temporal API以及Signals——及其对全栈JavaScript和TypeScript开发者的意义。