以前写后台接口,最让人抓狂的就是在业务逻辑开头写那一坨又一坨的入参校验代码。什么 if (!req.body.username)if (typeof req.body.age !== 'number')。稍微复杂一点的嵌套对象或者数组,手写校验逻辑能写到你想吐。更惨的是,前端传过来的 query 参数全是字符串,你还得手动转换 parseInt 或者 parseFloat,一不小心漏掉哪一步,直接导致后续业务代码抛出隐式空指针或者数据库类型报错,调试起来简直是地狱。

最致命的是,这种手写的校验逻辑跟 TypeScript 类型完全是脱节的。就算你在函数入参写了 interface UserDto,运行时的 JS 代码依然是裸奔状态。如果入参不干净,恶意请求可以直接带进各种奇奇怪怪的字段,直接污染后面的内存对象。

为了结束这种野路子校验,我们最近在后端所有的 Express/Koa 路由中全面引入了 Zod。Zod 不仅能在运行时帮你做好防线,做数据强清洗(Strip 掉没有声明的脏字段),最爽的是它能直接推导出 TypeScript 的静态类型,真正实现“定义即校验,校验即类型”。

以下是我们针对 Express 框架封装的通用 Zod 校验中间件。它支持同时校验 bodyqueryparams,并且校验通过后,会自动把强类型绑定到 Express 的 Request 范型上:

import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError, z } from 'zod';

// 定义一个通用的请求校验 Schema 结构
interface RequestValidators {
  body?: AnyZodObject;
  query?: AnyZodObject;
  params?: AnyZodObject;
}

export const validateRequest = (validators: RequestValidators) => {
  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      if (validators.params) {
        req.params = await validators.params.parseAsync(req.params);
      }
      if (validators.query) {
        req.query = await validators.query.parseAsync(req.query);
      }
      if (validators.body) {
        req.body = await validators.body.parseAsync(req.body);
      }
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        res.status(400).json({
          success: false,
          message: 'Validation failed',
          errors: error.errors.map((err) => ({
            field: err.path.join('.'),
            message: err.message,
          })),
        });
        return;
      }
      next(error);
    }
  };
};

// 业务调用示范:创建用户订单的接口校验 Schema
export const CreateOrderSchema = {
  body: z.object({
    productId: z.string().uuid({ message: '产品 ID 格式不正确' }),
    quantity: z.number().int().positive({ message: '数量必须是正整数' }),
    couponCode: z.string().trim().toUpperCase().optional(),
    meta: z.object({
      notes: z.string().max(200, { message: '备注不能超过 200 字' }).optional(),
      giftWrap: z.boolean().default(false),
    }),
  }),
  query: z.object({
    draft: z.enum(['true', 'false']).transform((val) => val === 'true'),
  }),
};

使用 Zod 的 transform 函数非常舒服。你看上面的 draft 字段,前端传过来的其实是个字面量字符串,我们直接在 Zod Schema 里面链式调用了 transform,将其安全地转换成了正儿八经的 boolean 类型。当代码走到 Controller 的时候,req.query.draft 已经是一个干净的 boolean 值了,不需要我们在业务层再去做烦人的类型转换。

另外,Zod 默认的 strip 机制也是个神器。如果前端不怀好意地多传了一些 Schema 之外的属性(比如试图修改 is_admin 字段),Zod 的 parseAsync 会直接把这部分多余属性砍掉,只保留 Schema 中定义好的字段。这种干净的输入流,让后面的安全防护心智负担骤降。

接入这套方案之后,团队的后端代码整洁度提升了一大截。以往冗长无聊的防御性代码缩减了 90% 以上。

不过用 Zod 也有个小细节要注意,就是在高并发的边缘计算节点上(比如 Cloudflare Workers),如果你的 Schema 极其庞大而且有复杂的递归定义,parse 可能会有轻微的性能损耗,但对于常规的 Web 应用,这种零星开销换来极致的安全性和可维护性,性价比简直高到爆。