在Koa中用Zod配合中间件做请求参数校验

在Koa中用Zod配合中间件做请求参数校验

2026-06-15 17:39:003 浏览1518作者:dreamk技术

在 Koa 中用 Zod 中间件做请求参数校验:一套可复用的实践

基于 koa-ts-starter 项目的真实代码整理。技术栈:Koa 3 + TypeScript + Zod 4。

为什么要在路由层之前做校验?

在 Koa 里,每个接口都要处理 bodyqueryparams 三类输入。如果把这些逻辑散落在各个 handler 里,很快会出现:

  • 重复的 if (!username) 判断
  • 类型和运行时行为不一致(TypeScript 只在编译期帮忙)
  • 错误格式不统一,前端难以处理

Zod 用 Schema 同时解决「运行时校验」和「类型推导」;再包一层 Koa 中间件,就能把校验从业务代码里抽出去,路由只关心「已经合法的数据」。

这套思路在本项目的核心只有约 25 行代码,但覆盖了从定义 Schema、挂载中间件、统一错误响应到测试的完整链路。

整体设计

请求进入后的数据流大致如下:

HTTP Request

bodyParser(解析 JSON body)

validate(schema) 中间件
    ├─ 聚合 body / query / params
    ├─ schema.parseAsync(...)
    ├─ 成功 → ctx.state.validated = 结果
    └─ 失败 → throw AppError(400, ...)

路由 handler(只读 ctx.state.validated)

responseHandler(全局 catch,映射为统一信封)

约定:Schema 顶层用 body / query / params 三个 key,与中间件自动注入的字段一一对应。这样一条 Schema 可以同时校验多种来源的参数。

核心中间件:validate

src/middlewares/validate.ts

import type { Context, Next } from 'koa';
import { ZodType, ZodError } from 'zod';
 
import { AppError } from '@/errors/AppError';
 
export const validate = <T extends Record<string, unknown>>(schema: ZodType<T>) => {
  return async (ctx: Context, next: Next) => {
    try {
      const validatedData = await schema.parseAsync({
        body: ctx.request.body,
        query: ctx.query,
        params: ctx.params,
      });
 
      ctx.state.validated = validatedData;
 
      await next();
    } catch (error) {
      if (error instanceof ZodError) {
        throw new AppError(400, error.issues[0]?.message ?? '参数校验失败');
      }
      throw error;
    }
  };
};

几个值得注意的设计点:

说明
高阶函数validate(schema) 返回标准 Koa 中间件,可按路由灵活挂载
parseAsync支持异步 Schema(如 refine 里查库),比同步 parse 更通用
写入 ctx.state校验结果挂在 state 上,不污染 ctx.request,handler 按需解构
ZodError → AppError把库级错误转成业务错误,交给全局错误处理统一输出

TypeScript 侧通过模块扩展声明 validated 字段(src/types/koa.d.ts):

declare module 'koa' {
  interface DefaultState {
    validated?: unknown;
  }
}

路由里用 z.infer<typeof schema> 做类型断言即可获得完整类型推导。

定义 Schema:从简单到进阶

基础示例

项目里 /validate 路由演示了 body 校验(src/routes/home.ts):

import { validate } from '@/middlewares/validate';
import Router from '@koa/router';
import z from 'zod';
 
const router = new Router();
 
const userSchema = z.object({
  body: z.object({
    username: z.string().min(3),
    age: z.coerce.number().min(1).default(1),
    ids: z
      .string()
      .transform((val) => val.split(',').map(Number))
      // 转化后可以继续接校验
      .pipe(z.array(z.number()).min(1, '至少需要一个ID')),
  }),
});
 
type UserReqData = z.infer<typeof userSchema>;
 
router.post('/validate', validate(userSchema), async (ctx) => {
  const { body } = ctx.state.validated as UserReqData;
  return ctx.ok({ body });
});
 
export default router;

挂载方式很直观:validate(schema) 放在 handler 前面

router.post('/validate', validate(userSchema), async (ctx) => { ... });

coerce:自动类型转换

HTTP 传来的 query 和部分 body 字段往往是字符串。z.coerce.number() 会先尝试 Number(val) 再校验,避免手写 parseInt

环境变量校验里同样用了这个能力(src/config/index.ts):

const envSchema = z.object({
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  ENV: z.string().default('development'),
  JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
  JWT_EXPIRES_IN: z.string().min(1).default('2h'),
});

transform + pipe:先变形,再校验

上面 ids 字段的写法很实用:

  1. 前端传 "1,2,3" 字符串
  2. transform 拆成 [1, 2, 3]
  3. pipe 接着用 z.array(z.number()) 校验

这样 handler 里直接拿到 number[],不用重复写解析逻辑。

同时校验 query 和 params

Schema 顶层结构固定后,扩展很自然:

import { PageSchema } from '@/schemas/base';
 
const updateUserSchema = z.object({
  query: PageSchema,
  params: z.object({ id: z.coerce.number() }),
  body: z.object({ name: z.string() }),
});
 
router.put('/users/:id', validate(updateUserSchema), async (ctx) => {
  const { query, params, body } = ctx.state.validated as z.infer<typeof updateUserSchema>;
  // 三者都已校验、已转型
});

复用公共 Schema

分页是高频需求,抽到 src/schemas/base.ts

import z from 'zod';
 
export const PageSchema = z.object({
  pageNo: z.coerce.number().min(1).default(1),
  pageSize: z.coerce.number().min(1).max(100).default(10),
});

在路由 Schema 里 query: PageSchemaquery: PageSchema.extend({ keyword: z.string().optional() }) 即可。

错误处理:校验失败如何返回给客户端

中间件抛出 AppError(400, message),全局 responseHandler 捕获后映射为统一信封(src/app/mapError.ts):

export function mapError(err: unknown): ApiBody<null> {
  if (err instanceof AppError) {
    return envelope.fail(err.code, err.expose ? err.message : 'error');
  }
 
  if (err instanceof ZodError) {
    return envelope.fail(400, err.issues[0]?.message ?? '参数校验失败');
  }
  // ...
}

响应格式:

{
  "code": 400,
  "msg": "String must contain at least 3 character(s)",
  "data": null
}

本项目约定:HTTP status 保持 200,业务语义看 body.code。校验失败时 code: 400,前端只需判断 code !== 0

AppError 本身很轻量(src/errors/AppError.ts):

export class AppError extends Error {
  constructor(
    public readonly code: number,
    message: string,
    public readonly expose = true,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

测试:用 Supertest 覆盖成功与失败分支

tests/http.test.ts

it('rejects invalid body on /validate', async () => {
  const res = await request(app.callback()).post('/validate').send({
    username: 'ab', // 不足 3 个字符
    age: 2,
    ids: '1,2',
  });
 
  expect(res.status).toBe(200);
  expect(res.body.code).toBe(400);
  expect(res.body.data).toBeNull();
});
 
it('accepts valid body on /validate', async () => {
  const res = await request(app.callback()).post('/validate').send({
    username: 'abcd',
    age: 2,
    ids: '1,2,3',
  });
 
  expect(res.status).toBe(200);
  expect(res.body.code).toBe(0);
  expect(res.body.data.body.ids).toEqual([1, 2, 3]);
});

注意第二个断言:ids 从字符串 "1,2,3" 被 transform 成了 [1, 2, 3],说明中间件 + Schema 的整条链路是通的。

中间件注册顺序

validate 依赖 bodyParser 先解析 body,因此顺序不能错(src/app/index.ts):

app.use(cors());
app.use(bodyParser()); // ① 先解析 body
app.use(requestLogger);
app.use(responseHandler); // ② 全局错误捕获(需在路由外层)
 
app.use(router.routes()).use(router.allowedMethods());

validate 挂在具体路由上,不需要全局注册。

可以进一步优化的方向

当前实现已经够用,如果项目变大,可以考虑:

  1. 返回完整 issues 列表
    现在只取 error.issues[0]?.message,适合简单场景。表单类接口可以改成:

    { code: 400, msg: '参数校验失败', data: error.flatten() }
  2. 按字段定制错误文案
    z.string().min(3, '用户名至少 3 个字符') 比 Zod 默认英文 message 更友好。

  3. 抽取 createValidate 工厂
    如果不同项目对 body/query/params 的聚合方式不同,可以把聚合逻辑参数化。

  4. 与 OpenAPI 联动
    Zod Schema 可通过 zod-to-openapi 等工具生成 API 文档,Schema 即文档。

小结

环节做法
Schema 定义顶层 body / query / params,内层用 Zod 链式校验
中间件validate(schema) 聚合输入 → parseAsync → 写入 ctx.state.validated
路由validate 放 handler 前,用 z.infer 取类型
错误ZodErrorAppError(400) → 全局 mapError → 统一信封
复用公共 Schema(如 PageSchema)+ coerce / transform / pipe

整套方案的核心价值是:校验逻辑声明式、可测试、类型安全,业务 handler 只处理干净数据。中间件不到 30 行,却能覆盖 body、query、params 三类输入,适合作为 Koa + TypeScript 项目的标准起手式。

相关文件索引

文件说明
src/middlewares/validate.ts校验中间件
src/routes/home.ts/validate 示例路由
src/schemas/base.ts公共 PageSchema
src/errors/AppError.ts业务错误类
src/app/mapError.ts错误映射
src/app/responseHandler.ts全局响应处理
src/types/koa.d.tsctx.state.validated 类型扩展
tests/http.test.ts集成测试

相关项目github地址

koa3-ts-starter

评论区

0 条评论

还没有评论,欢迎成为第一个留言的人。