Express 5 + TypeScript + express-validator 练习答案(一)
对应 题目,本册覆盖 1~8 题。示例以 express-validator@7.3.2、Express 5、Node.js 22+ 为准。每个请求先经过验证链,再由统一的 validationHandler 决定是否进入路由处理器。
import type { RequestHandler } from "express";
import { validationResult } from "express-validator";
export const validationHandler: RequestHandler = (req, res, next) => {
const result = validationResult(req);
if (result.isEmpty()) return next();
const seen = new Set<string>();
const errors = result.array().flatMap((error) => {
if (error.type !== "field") return [];
const key = `${error.location}.${error.path}`;
if (seen.has(key)) return [];
seen.add(key);
return [{ field: error.path, location: error.location, message: error.msg }];
});
return res.status(422).json({
code: "VALIDATION_FAILED",
message: "请求参数验证失败",
errors,
});
};
练习 1:查询参数分页
import { matchedData, query } from "express-validator";
interface UserListInput { page: number; pageSize: number; keyword?: string }
const listUsersValidation = [
query("page").optional().isInt({ min: 1 }).withMessage("page 必须是正整数").toInt(),
query("pageSize").optional().isInt({ min: 1, max: 100 })
.withMessage("pageSize 必须是 1 到 100 的整数").toInt(),
query("keyword").optional().isString().trim().isLength({ max: 100 })
.withMessage("keyword 最多 100 个字符"),
validationHandler,
];
app.get("/api/users", listUsersValidation, (req, res) => {
const raw = matchedData(req, { locations: ["query"] }) as Partial<UserListInput>;
const input: UserListInput = {
page: raw.page ?? 1,
pageSize: raw.pageSize ?? 20,
...(raw.keyword === undefined ? {} : { keyword: raw.keyword }),
};
res.json(input);
});
toInt() 使运行时值成为 number;matchedData 的泛型/断言不能凭空保证运行时正确,所以仍应让验证规则与接口类型同步。默认值应在验证后补充,避免把未验证的 req.query 送入 Service。
练习 2:路径参数详情
const getUserValidation = [
param("userId").isInt({ min: 1 }).withMessage("userId 必须是正整数").toInt(),
validationHandler,
];
app.get("/api/users/:userId", getUserValidation, async (req, res) => {
const { userId } = matchedData(req, { locations: ["params"] }) as { userId: number };
const user = await userRepository.findById(userId);
if (!user) return res.status(404).json({ code: "USER_NOT_FOUND" });
return res.json(user);
});
abc、0 是客户端输入无效,返回 422;12 格式正确而数据不存在,返回 404。这不是同一种错误。
练习 3:用户注册
interface CreateUserInput { name: string; email: string; password: string; age?: number }
const registerValidation = [
body("name").isString().withMessage("name 必须是字符串").trim().isLength({ min: 2, max: 50 }),
body("email").isEmail().withMessage("email 格式不正确").normalizeEmail(),
body("password").isString().withMessage("password 必须是字符串")
.isLength({ min: 8, max: 128 }).withMessage("password 长度必须是 8 到 128"),
body("passwordConfirmation").custom((value, { req }) => {
if (value !== req.body.password) throw new Error("两次密码不一致");
return true;
}),
body("age").optional().isInt({ min: 0, max: 150 }).withMessage("age 必须是 0 到 150 的整数").toInt(),
validationHandler,
];
app.post("/api/users", registerValidation, async (req, res) => {
const data = matchedData(req, { locations: ["body"] }) as CreateUserInput & { passwordConfirmation: string };
const { passwordConfirmation: _confirmation, ...input } = data;
const user = await userService.create(input);
return res.status(201).json({ id: user.id, name: user.name, email: user.email });
});
密码不能 trim():那会悄悄改变用户输入。不要记录 req.body 或验证错误中的 value,响应也绝不能回显密码。
练习 4:PATCH 部分更新
const updateRules = [
body("name").optional().isString().trim().isLength({ min: 2, max: 50 }),
body("age").optional().isInt({ min: 0, max: 150 }).toInt(),
body("bio").optional().isString().trim().isLength({ max: 500 }),
body().custom((value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length === 0) {
throw new Error("至少提交一个可更新字段");
}
return true;
}),
];
app.patch("/api/users/:userId", [
param("userId").isInt({ min: 1 }).toInt(),
...updateRules,
validationHandler,
], async (req, res) => {
const data = matchedData(req, { locations: ["params", "body"] }) as {
userId: number; name?: string; age?: number; bio?: string;
};
const { userId, ...patch } = data;
await userService.updateProfile(userId, patch);
return res.sendStatus(204);
});
这是“忽略未知字段”版:role、balance 不会进入 patch。严格版在所有字段验证链之后加 checkExact():
import { checkExact } from "express-validator";
app.patch("/api/users/:userId", [param("userId").isInt({ min: 1 }).toInt(), ...updateRules, checkExact(), validationHandler], handler);
兼容旧客户端时可先忽略未知字段;管理端、资金和权限接口更适合拒绝。注意空对象规则检查的是原 body,因此 { role: "admin" } 在严格版会得到未知字段错误,但忽略版还应额外检查 matchedData 是否至少有一个允许字段。
练习 5:日期范围
const orderRangeValidation = [
query("startDate").isISO8601({ strict: true, strictSeparator: true }).withMessage("startDate 必须是 YYYY-MM-DD"),
query("endDate").isISO8601({ strict: true, strictSeparator: true }).withMessage("endDate 必须是 YYYY-MM-DD")
.custom((end: string, { req }) => {
const start = String(req.query.startDate);
const startMs = Date.parse(`${start}T00:00:00.000Z`);
const endMs = Date.parse(`${end}T00:00:00.000Z`);
if (endMs < startMs) throw new Error("endDate 不能早于 startDate");
if (endMs - startMs > 90 * 86_400_000) throw new Error("查询跨度不能超过 90 天");
return true;
}),
validationHandler,
];
Repository 不要 DATE(created_at),否则通常无法利用索引:
WHERE created_at >= ? AND created_at < DATE_ADD(?, INTERVAL 1 DAY)
应统一业务时区。isISO8601 不是所有非法日历日期的唯一防线;实际项目建议用严格日期库或自行比对格式化结果,明确拒绝 2026-02-31。
练习 6:地址数组
const addressesValidation = [
body("addresses").isArray({ min: 1, max: 5 }).withMessage("addresses 必须有 1 到 5 项"),
body("addresses.*.label").isIn(["home", "work"]).withMessage("label 只能是 home 或 work"),
body("addresses.*.city").isString().trim().notEmpty().withMessage("city 不能为空").isLength({ max: 50 }),
body("addresses.*.postalCode").matches(/^\d{6}$/).withMessage("postalCode 必须是 6 位中国邮编"),
body("addresses").custom((addresses: unknown) => {
if (!Array.isArray(addresses)) return true; // 数组规则负责报错
const labels = addresses.map((item) =>
item && typeof item === "object" && "label" in item ? (item as { label?: unknown }).label : undefined,
);
if (new Set(labels).size !== labels.length) throw new Error("label 不允许重复");
return true;
}),
validationHandler,
];
字段格式用 addresses.*,跨元素重复性用数组级 custom;是否允许同一用户有两个 home 地址则是业务规则,应放 Service/数据库约束。
练习 7:联系方式多组选一
import { body, oneOf } from "express-validator";
const contactValidation = [
oneOf([
body("email").exists().isEmail().withMessage("email 格式不正确"),
body("phone").exists().matches(/^1[3-9]\d{9}$/).withMessage("phone 格式不正确"),
], { message: "email 或 phone 至少提供一个且格式正确" }),
validationHandler,
];
oneOf 默认允许两项都存在。若要“恰好一个”,另加对象级 custom:
body().custom((value: unknown) => {
const data = value as { email?: unknown; phone?: unknown };
const count = Number(data.email !== undefined) + Number(data.phone !== undefined);
if (count !== 1) throw new Error("email 和 phone 必须且只能提供一个");
return true;
})
第 12 题的错误处理中会专门格式化 alternative / alternative_grouped。
练习 8:条件字段
if() 版易读,字段独立时优先:
const companyWithIf = [
body("accountType").isIn(["personal", "company"]),
body("companyName").if(body("accountType").equals("company"))
.isString().trim().notEmpty().withMessage("公司账户必须填写 companyName"),
body("taxNumber").if(body("accountType").equals("company"))
.isString().trim().notEmpty().withMessage("公司账户必须填写 taxNumber"),
body("companyName").if(body("accountType").equals("personal")).not().exists()
.withMessage("个人账户不能提交 companyName"),
body("taxNumber").if(body("accountType").equals("personal")).not().exists()
.withMessage("个人账户不能提交 taxNumber"),
];
custom 版适合多个字段整体约束:
body().custom((value: unknown) => {
const data = value as { accountType?: unknown; companyName?: unknown; taxNumber?: unknown };
const hasCompanyField = data.companyName !== undefined || data.taxNumber !== undefined;
if (data.accountType === "company" && (!data.companyName || !data.taxNumber)) {
throw new Error("公司账户必须填写企业信息");
}
if (data.accountType === "personal" && hasCompanyField) {
throw new Error("个人账户不能提交企业信息");
}
return true;
})
前者把错误定位到字段,后者表达整体状态更集中。两者都只负责输入形状;“税号是否真实有效”若需要外部服务,应在 Service 中处理失败策略。