03 ValidationChain 进阶用法

1. .custom() 自定义 validator

内置 validator 无法表达所有业务格式,可以使用:

.custom((value, meta) => boolean | Promise<unknown>)

同步校验

body("startDate").isISO8601(),
body("endDate")
  .isISO8601()
  .bail()
  .custom((endDate: string, { req }) => {
    const startDate = String(req.body.startDate);

    if (new Date(endDate) < new Date(startDate)) {
      throw new Error("endDate 不能早于 startDate");
    }

    return true;
  });

自定义 validator 可以:

使用 .withMessage() 可以覆盖 custom 抛出的消息。

异步校验

body("email")
  .isString()
  .bail()
  .isEmail()
  .bail()
  .custom(async (email: string) => {
    const user = await User.findOne({ where: { email } });

    if (user !== null) {
      throw new Error("邮箱已经存在");
    }

    return true;
  });

便宜的类型和格式校验应放在数据库查询前,并使用 .bail()

但“先查不存在,再创建”存在并发竞态。MySQL 仍必须建立唯一约束,并在写入时处理唯一键冲突。

2. .customSanitizer()

body("displayName")
  .customSanitizer((value: unknown) => String(value).trim());

自定义 sanitizer 返回的新值会替换原值。它不能用来表示“校验失败”,如果转换需要拒绝非法输入,应先使用 validator。

例如把逗号文本转换成数组:

body("tags")
  .isString()
  .bail()
  .customSanitizer((value: string) =>
    value
      .split(",")
      .map((tag) => tag.trim())
      .filter((tag) => tag.length > 0),
  )
  .isArray({ max: 10 });

如果 API 本来约定接收 JSON 数组,最好直接要求数组,不要为了迁就错误格式做过多隐式转换。

3. .if() 条件校验

body("companyName")
  .if(body("accountType").equals("company"))
  .isString()
  .trim()
  .notEmpty();

也可以传函数:

body("reason")
  .if((_value, { req }) => req.body.status === "rejected")
  .isString()
  .trim()
  .isLength({ min: 1, max: 200 });

.if() 适合决定一条链是否运行。复杂状态机规则更适合放在 Service,而不是堆积成难以阅读的 validator。

4. .not() 反转下一项验证

body("username")
  .not()
  .isIn(["admin", "root", "system"])
  .withMessage("该用户名不可使用");

.not() 只反转紧随其后的 validator,不会反转后面整条链。

5. .default().replace()

默认值

query("page")
  .default(1)
  .isInt({ min: 1 })
  .toInt();

默认值属于 sanitizer,会改变请求数据。默认值是否属于 HTTP 层约定,需要在接口文档中明确。

替换指定值

body("nickname")
  .replace(["N/A", "unknown"], null)
  .optional({ values: "null" })
  .isString();

不要用大量隐式替换掩盖客户端协议不一致。

6. .hide() 隐藏敏感值

body("password")
  .isStrongPassword()
  .hide();

错误对象通常包含非法值。对密码、Token、验证码等字段使用 .hide(),可以避免它们进入响应或日志。

也可以指定替代文本:

.hide("*****")

7. 嵌套字段

{
  "profile": {
    "name": "Tom",
    "address": {
      "city": "Shanghai"
    }
  }
}
body("profile.name").isString();
body("profile.address.city").isString();

字段名本身包含点号时,需要使用带引号的 bracket 语法:

body('settings["notification.email"]').isBoolean();

8. Wildcard:数组和动态对象

请求体:

{
  "items": [
    { "productId": 1, "quantity": 2 },
    { "productId": 5, "quantity": 1 }
  ]
}

校验:

export const orderValidation = [
  body("items")
    .isArray({ min: 1, max: 50 })
    .withMessage("items 必须包含 1 到 50 项"),

  body("items.*.productId")
    .isInt({ min: 1 })
    .toInt(),

  body("items.*.quantity")
    .isInt({ min: 1, max: 100 })
    .toInt(),
];

先限制数组长度,避免攻击者提交巨大数组,导致成千上万条校验和数据库查询。

自定义 validator 可以获得通配符对应值:

body("items.*.quantity")
  .custom((quantity: number, { pathValues }) => {
    console.log(pathValues);
    return quantity <= 100;
  });

9. Globstar

** 可以匹配任意深度的嵌套字段:

body("teams.**.name").isString();

它适合树形数据,但应限制树的深度和节点数量。对任意大型 JSON 递归校验可能消耗大量 CPU。

10. 校验整个请求体

省略字段名可以选择整个位置:

body().isArray();

例如接口直接接收 ID 数组:

[1, 2, 3]
body().isArray({ min: 1, max: 100 }),
body("*").isInt({ min: 1 }).toInt();

11. 复用规则的正确方式

推荐使用函数创建新的链:

import {
  body,
  param,
  type ValidationChain,
} from "express-validator";

function positiveId(field: string): ValidationChain {
  return param(field)
    .isInt({ min: 1 })
    .withMessage(`${field} 必须是正整数`)
    .toInt();
}

function requiredShortText(
  field: string,
  max: number,
): ValidationChain {
  return body(field)
    .isString()
    .bail()
    .trim()
    .isLength({ min: 1, max });
}

不要创建一条全局链后在多个地方继续追加方法,因为 ValidationChain 是可变对象。

12. 什么不应该放在 custom validator

不建议放入:

validator 应尽量是可重复执行的检查。权限、状态流转和事务属于 Service。

13. 练习题

  1. 校验“开始日期不得晚于结束日期”的请求体。
  2. 为订单 items 数组添加容器、productId、quantity 校验。
  3. 只有 accountType=company 时才要求填写统一社会信用代码。
  4. 编写异步邮箱查重 validator,并说明为什么还需要 MySQL 唯一约束。
  5. 对 password 和 verificationCode 使用 .hide(),观察错误响应差异。
  6. 编写两个返回全新 ValidationChain 的复用函数,不复用可变链实例。

官方参考