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),
  };
}

不能假设所有错误都有 path 和 location,因为 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. 实战:为什么 .toInt() 后的 req.query 仍可能是字符串

假设管理员列表接口收到下面的请求:

GET /api/admin/users?page=1&page_size=10&account_status=1

查询字符串来自 URL,最初解析出来的值都是字符串:

query("page").isInt({ min: 1 }).toInt();
query("account_status").isIn(["0", "1", "2"]).toInt();

这里要区分三件事:

  1. .isInt() 检查输入是否能表示整数,不会把值变成 number;
  2. .toInt() 才执行运行时转换;
  3. TypeScript 类型声明只影响类型检查,不会转换 HTTP 输入。

11.1 Express 5 的 req.query 是 getter

Express 5 根据当前 URL 解析查询字符串,并通过 getter 提供 req.query。因此, sanitizer 在一次访问中处理过的查询对象,不应被当作 Controller 之后再次读取 req.query 时的稳定数据来源。再次访问时,仍可能得到由 URL 重新解析出的字符串:

console.log(req.query.page, typeof req.query.page);
// "1", "string"

express-validator 自己的校验上下文会保存链执行后的值,matchedData() 正是从这些 上下文中提取已匹配的数据:

interface ValidatedAdminListQuery {
  page?: number;
  page_size?: number;
  account_status?: number;
}

const queryData = matchedData<ValidatedAdminListQuery>(req, {
  locations: ["query"],
});

console.log(queryData.page, typeof queryData.page);
// 1, "number"

因此,在当前 Express 5 项目中,Controller 应把 matchedData() 的结果作为 “已经校验并转换的输入”,再交给 Service:

export async function adminListController(
  req: Request<EmptyParams, unknown, unknown>,
  res: Response<ApiResponse<AdminList>>,
): Promise<void> {
  const queryData = matchedData<ValidatedAdminListQuery>(req, {
    locations: ["query"],
  });

  const result = await findAdmins({
    page: queryData.page ?? 1,
    page_size: queryData.page_size ?? 10,
    account_status: queryData.account_status ?? null,
  });

  res.json({ code: "OK", message: "success", data: result });
}

11.2 不要用 Request 泛型“假装转换已经发生”

下面的声明只会让 TypeScript 相信 page 是 number:

interface AdminListQuery {
  page?: number;
}

req: Request<EmptyParams, unknown, unknown, AdminListQuery>

它不会把 URL 中的 ?page=1 转换成数字。如果 Controller 随后直接读取 req.query.page,编译器认为它是 number,运行时却可能仍是字符串。这种情况 比类型报错更危险,因为错误的类型标注隐藏了真实的数据边界。

更合适的做法是:

11.3 params、body 与 query 的区别

需要注意,必须先运行 ValidationChain,再运行 validateRequest,最后才在 Controller 中调用 matchedData():

router.get(
  "/users",
  queryPaginationValidators,
  validateRequest,
  adminListController,
);

推荐为这类接口编写一条集成测试,同时断言值和类型:

expect(result.page).toBe(1);
expect(typeof result.page).toBe("number");

只断言数据库查询“碰巧成功”,可能发现不了字符串一直流入 Service 的问题。

.isInt()、.toInt()、.default() 和 .optional() 的详细执行顺序参见 ValidationChain 高频 API:链的执行顺序。

12. TypeScript 泛型的边界

const input = matchedData<CreateWishInput>(req);

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

因此要保持三者同步:

ValidationChain
DTO interface
Controller/Service 使用方式

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

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

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

res.locals.validated = matchedData(req);

Controller 再读取:

const input = res.locals.validated as CreateWishInput;

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

14. 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 用于稳定逻辑。

15. 练习题

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

官方参考