
在 Koa 中用 Zod 中间件做请求参数校验:一套可复用的实践
基于 koa-ts-starter 项目的真实代码整理。技术栈:Koa 3 + TypeScript + Zod 4。
为什么要在路由层之前做校验?
在 Koa 里,每个接口都要处理 body、query、params 三类输入。如果把这些逻辑散落在各个 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,2,3"字符串 transform拆成[1, 2, 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: PageSchema 或 query: 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 挂在具体路由上,不需要全局注册。
可以进一步优化的方向
当前实现已经够用,如果项目变大,可以考虑:
-
返回完整
issues列表
现在只取error.issues[0]?.message,适合简单场景。表单类接口可以改成:{ code: 400, msg: '参数校验失败', data: error.flatten() } -
按字段定制错误文案
z.string().min(3, '用户名至少 3 个字符')比 Zod 默认英文 message 更友好。 -
抽取
createValidate工厂
如果不同项目对body/query/params的聚合方式不同,可以把聚合逻辑参数化。 -
与 OpenAPI 联动
Zod Schema 可通过zod-to-openapi等工具生成 API 文档,Schema 即文档。
小结
| 环节 | 做法 |
|---|---|
| Schema 定义 | 顶层 body / query / params,内层用 Zod 链式校验 |
| 中间件 | validate(schema) 聚合输入 → parseAsync → 写入 ctx.state.validated |
| 路由 | validate 放 handler 前,用 z.infer 取类型 |
| 错误 | ZodError → AppError(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.ts | ctx.state.validated 类型扩展 |
tests/http.test.ts | 集成测试 |
评论区
0 条评论还没有评论,欢迎成为第一个留言的人。