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

abc0 是客户端输入无效,返回 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);
});

这是“忽略未知字段”版:rolebalance 不会进入 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 中处理失败策略。