04 错误结果与 matchedData

1. validationResult()

验证链不会自动结束请求。使用下面的函数读取当前请求积累的错误:

import { validationResult } from "express-validator";

const errors = validationResult(req);

返回值是 Result<ValidationError>,常用方法包括:

isEmpty()
array()
mapped()
formatWith()
throw()

2. .isEmpty().array()

const errors = validationResult(req);

if (!errors.isEmpty()) {
  res.status(400).json({ errors: errors.array() });
  return;
}

array() 返回错误数组。设置 onlyFirstError 可以每个字段只保留第一个错误:

errors.array({ onlyFirstError: true });

它与 .bail({ level: "request" }) 不同:

3. .mapped()

const mappedErrors = errors.mapped();

它按字段路径生成对象,适合表单字段错误:

{
  "name": {
    "type": "field",
    "msg": "name 不能为空",
    "path": "name",
    "location": "body"
  }
}

同一字段存在多个错误时,只会保留一个映射值。如果需要展示全部错误,使用 array()

4. 错误类型

express-validator 7 的错误是可区分联合类型,常见类型包括:

FieldValidationError
AlternativeValidationError
GroupedAlternativeValidationError
UnknownFieldValidationError

处理字段错误时应先判断 type

import type { ValidationError } from "express-validator";

function formatError(error: ValidationError) {
  if (error.type === "field") {
    return {
      field: error.path,
      location: error.location,
      message: String(error.msg),
    };
  }

  return {
    message: String(error.msg),
  };
}

不能假设所有错误都有 pathlocation,因为 oneOf()checkExact() 会产生其他类型。

5. .formatWith()

const formatted = validationResult(req).formatWith((error) => {
  if (error.type === "field") {
    return {
      field: error.path,
      message: String(error.msg),
    };
  }

  return {
    message: String(error.msg),
  };
});

然后:

res.status(400).json({ errors: formatted.array() });

formatWith() 返回一个新的 Result,不会改变原 Result 的泛型类型。

6. 项目级 withDefaults()

import {
  validationResult,
  type ValidationError,
} from "express-validator";

interface ApiValidationError {
  code: string;
  field?: string;
  message: string;
}

export const appValidationResult = validationResult.withDefaults<
  ApiValidationError
>({
  formatter(error: ValidationError): ApiValidationError {
    if (error.type === "field") {
      return {
        code: "INVALID_FIELD",
        field: error.path,
        message: String(error.msg),
      };
    }

    return {
      code: "INVALID_REQUEST",
      message: String(error.msg),
    };
  },
});

统一中间件:

export function validateRequest(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const errors = appValidationResult(req);

  if (!errors.isEmpty()) {
    res.status(400).json({
      code: "VALIDATION_ERROR",
      errors: errors.array(),
    });
    return;
  }

  next();
}

7. 不要泄露非法值

默认字段错误可能包含 value。如果字段是密码或 Token:

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

统一 formatter 也不应把 error.value 原样返回。日志同样需要脱敏。

8. .throw()

validationResult(req).throw();

存在错误时会抛出异常。它适合统一异常流,但需要确保错误处理中间件能区分验证错误,不要把它当成普通 500。

对于当前学习项目,显式 isEmpty() 判断更容易理解。

9. matchedData() 为什么重要

即使验证了两个字段,req.body 仍可能包含其他内容:

{
  "name": "学习 TypeScript",
  "content": "完成校验教程",
  "isAdmin": true
}

下面的代码有 Mass Assignment 风险:

await Wish.create(req.body);

只提取被验证字段:

import { matchedData } from "express-validator";

interface CreateWishInput {
  name: string;
  content: string;
}

const input = matchedData<CreateWishInput>(req);

await Wish.create(input);

10. matchedData() options

matchedData(req, {
  includeOptionals: false,
  onlyValidData: true,
  locations: ["body"],
});

includeOptionals

是否包含被 .optional() 选中但未提供的字段。

onlyValidData

默认为 true,只提取通过验证的数据。通常不要改为 false

locations

限制提取位置:

locations: ["body", "params"]

避免 body 和 query 同名字段混入结果。

11. TypeScript 泛型的边界

const input = matchedData<CreateWishInput>(req);

这个泛型不会自动证明验证规则与接口完全一致。如果校验链漏掉 content,TypeScript 仍可能相信它存在。

因此要保持三者同步:

ValidationChain
DTO interface
Controller/Service 使用方式

对重要接口编写集成测试,验证实际输出结构。

12. 在中间件中挂载可信数据

一种项目实践是验证成功后统一提取:

res.locals.validated = matchedData(req);

Controller 再读取:

const input = res.locals.validated as CreateWishInput;

这需要扩展 ResponseLocals 类型。小项目直接在 Controller 调用 matchedData<T>() 更直观,避免过早抽象。

13. i18n 消息 key

校验规则可以保存稳定 key:

.withMessage("validation.wish.name.required")

formatter 根据请求语言翻译:

message: req.t(String(error.msg))

响应可以同时返回:

{
  "code": "validation.wish.name.required",
  "field": "name",
  "message": "名称不能为空"
}

前端使用 message 展示,code 用于稳定逻辑。

14. 练习题

  1. errors.array() 转换成只包含 field、code、message 的响应。
  2. 使用 withDefaults() 创建项目级 ResultFactory。
  3. 对 password 使用 .hide(),确认响应中不再出现原密码。
  4. matchedData() 阻止客户端提交额外的 isAdmin 字段。
  5. 分别测试 includeOptionals 为 true 和 false 的结果。
  6. oneOf()checkExact() 错误补充非 field 类型的 formatter 分支。

官方参考