首页 / 文章 / Zod与express-validator:两种Express验证方案

Zod与express-validator:两种Express验证方案

对比了基于模式的第一请求验证方式与Zod,以及基于链式的express-validator中间件,在设置、错误格式化及常见问题方面进行了阐述。

1438 词

处理不可信的输入是任何 Express API 首要解决的难题之一,实现这一目标的方法不止一种——既有基于模式定义的库,也有更偏向流程化、链式结构的验证工具。本文将探讨这两种方法,首先介绍基于 Zod 的模式驱动型方案。

使用 Zod 验证请求

Express 本身不会对传入的数据进行任何验证。如果没有在边界处进行检查,路由处理程序会直接收到原始的 req.bodyreq.queryreq.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 expressnpm 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
    }))
  };
}

验证中间件

在路由处理程序执行之前先对 bodyqueryparams 进行验证,然后将解析后的值写回,这样处理程序就能收到已类型化且转换过的数据。

// 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

关键不在于仅仅确认字符串是否类似电子邮件地址——而是要尽可能早地拒绝无效输入,从而保证整个应用程序的整洁性。

相关阅读

  • RFC 9457详解:标准化HTTP API错误响应 — 了解RFC 9457中的问题详情格式如何实现HTTP API错误响应的标准化,以及如何在NestJS应用中正确实施该标准。
  • 在React前端与Node后端之间共享同一个Zod Schema — 了解如何利用单个Zod Schema对React表单、API响应、Express请求体以及环境变量进行验证,同时生成对应的TypeScript类型。
  • req-guard-lite:专为 Express 设计的极简型 TypeScript 限流器 — 了解这款轻量级、无依赖的 Express 限流器的工作原理,从内存默认配置到 Redis 扩展以及自定义键生成器都有涉及。