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" }) 不同:
onlyFirstError只改变错误输出;所有校验可能已经执行;bail会真正阻止后续 validator 执行。
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();
这里要区分三件事:
.isInt()检查输入是否能表示整数,不会把值变成number;.toInt()才执行运行时转换;- 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,运行时却可能仍是字符串。这种情况
比类型报错更危险,因为错误的类型标注隐藏了真实的数据边界。
更合适的做法是:
- 让
req保持对原始 HTTP 输入的保守描述; - 为
matchedData()单独定义“校验后 DTO”; - Service 只接收校验后 DTO 或明确的业务参数类型。
11.3 params、body 与 query 的区别
- 普通
req.params路由参数来自 URL,初始值通常也是字符串; req.body由express.json()等中间件赋值,不是 Express 5 的 query getter;req.query在 Express 5 中是 getter,是本案例最容易忽略的差异;- 无论数据来自哪个位置,都建议通过
locations限定来源并读取matchedData(),这样既能获得 sanitizer 的结果,也能建立字段白名单。
需要注意,必须先运行 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. 练习题
- 将
errors.array()转换成只包含 field、code、message 的响应。 - 使用
withDefaults()创建项目级 ResultFactory。 - 对 password 使用
.hide(),确认响应中不再出现原密码。 - 用
matchedData()阻止客户端提交额外的isAdmin字段。 - 分别测试
includeOptionals为 true 和 false 的结果。 - 为
oneOf()和checkExact()错误补充非 field 类型的 formatter 分支。 - 发送
?page=1,分别观察req.query.page和matchedData(req).page的值及typeof,解释 Express 5 中结果不同的原因。