Request validation with Zod in Express
Request validation with Zod in Express
在 Express 中使用 Zod 进行请求验证
Express does not validate request input for you. Without a check at the edge, handlers get raw req.body, req.query, and req.params - strings where you expected numbers, missing fields, and shapes that only blow up deep in business logic.
Express 不会为你验证请求输入。如果在入口处没有检查,处理程序(handler)接收到的将是原始的 req.body、req.query 和 req.params —— 你期望得到数字的地方却收到了字符串,或者遇到字段缺失,这些问题往往直到业务逻辑深处才会引发崩溃。
Zod is a TypeScript-first schema library. You declare the shape once, infer types with z.infer, and parse at the HTTP boundary so route handlers only see valid data. Invalid input becomes HTTP 400 before your code runs.
Zod 是一个以 TypeScript 为优先的模式(schema)库。你只需声明一次数据结构,通过 z.infer 推断类型,并在 HTTP 边界处进行解析,这样路由处理程序就只会接收到有效数据。无效的输入会在你的代码运行前直接返回 HTTP 400 错误。
This post covers Zod 4 schemas (z.email(), z.uuid(), z.coerce), Express validation middleware, error formatting and pitfalls.
本文将涵盖 Zod 4 的模式(如 z.email()、z.uuid()、z.coerce)、Express 验证中间件、错误格式化以及常见陷阱。
Prerequisites: Node.js version 26, Zod 4: npm i zod, Express: npm i express and npm i -D @types/express.
前置条件:Node.js 26 版本,Zod 4:npm i zod,Express:npm i express 以及 npm i -D @types/express。
Zod 3 method forms like z.string().email() still work but are deprecated in v4. Prefer the top-level APIs below.
Zod 3 的方法形式(如 z.string().email())虽然仍可使用,但在 v4 中已被弃用。建议优先使用下方的顶级 API。
Schemas
模式定义
// schemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParamSchema = z.object({
id: z.uuid()
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().min(1).optional()
});
z.coerce.number() is useful for query strings - HTTP query values arrive as strings. Prefer safeParse over parse at the edge so you control the HTTP status and response body.
z.coerce.number() 对于查询字符串非常有用,因为 HTTP 查询参数传入时都是字符串。在边界处,建议优先使用 safeParse 而非 parse,这样你可以自行控制 HTTP 状态码和响应体。
Format errors once: Map ZodError.issues into a stable JSON body, or use Zod 4 helpers z.flattenError() / z.treeifyError() when you want field-keyed or nested shapes.
统一格式化错误:将 ZodError.issues 映射为稳定的 JSON 响应体,或者在需要字段键值对或嵌套结构时,使用 Zod 4 的辅助函数 z.flattenError() 或 z.treeifyError()。
// format-zod-error.ts
import { ZodError } from 'zod';
export function formatZodError(error: ZodError) {
return {
message: 'Validation failed',
issues: error.issues.map((issue) => ({
path: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code
}))
};
}
Validation middleware
验证中间件
Validate body, query, and params before the route handler. Write parsed data back so handlers receive typed, coerced values. 在路由处理程序之前验证 body、query 和 params。将解析后的数据写回,以便处理程序接收到已类型化且经过转换的值。
// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';
type RequestSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};
export function validate(schemas: RequestSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const parseOrReject = (schema: ZodType, value: unknown) => {
const parsed = schema.safeParse(value);
if (!parsed.success) {
res.status(400).json(formatZodError(parsed.error));
return null;
}
return parsed.data;
};
if (schemas.body) {
const body = parseOrReject(schemas.body, req.body);
if (body === null) return;
req.body = body;
}
if (schemas.query) {
const query = parseOrReject(schemas.query, req.query);
if (query === null) return;
res.locals.query = query;
}
if (schemas.params) {
const params = parseOrReject(schemas.params, req.params);
if (params === null) return;
res.locals.params = params;
}
next();
};
}
Wire it per route: 在路由中应用:
app.post('/users', validate({ body: createUserSchema }), (req, res) => {
// req.body is CreateUserInput
res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});
app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
const { limit, q } = res.locals.query;
// ...
});
app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
const { id } = res.locals.params;
// ...
});
Query and params are stored on res.locals because Express types treat req.query / req.params as string maps; replacing them with coerced objects fights the type system.
Query 和 params 被存储在 res.locals 中,因为 Express 的类型定义将 req.query 和 req.params 视为字符串映射;如果直接替换为转换后的对象,会与 Express 的类型系统产生冲突。
Pitfalls
常见陷阱
- Query strings are strings: use
z.coerce(orz.string() + transform) for numbers and booleans. 查询字符串即字符串:对于数字和布尔值,请使用z.coerce(或z.string() + transform)。 - parse vs safeParse:
parsethrows a rawZodError; map it to HTTP 400 yourself or stick to safeParse. parse 与 safeParse:parse会抛出原始的ZodError;请自行将其映射为 HTTP 400 错误,或者坚持使用safeParse。 - Strip unknown keys: Zod object schemas strip unknown keys by default. Use
.strict()if you want to reject them. 剔除未知键:Zod 对象模式默认会剔除未知键。如果你希望拒绝这些键,请使用.strict()。 - Do not trust types alone:
CreateUserInputis compile-time only; always parse at the boundary. 不要仅信任类型:CreateUserInput仅在编译时有效;务必在边界处进行解析。 - Stricter UUIDs in Zod 4:
z.uuid()follows RFC 9562/4122 more strictly; usez.guid()for a looser 8-4-4-4-12 hex pattern. Zod 4 中更严格的 UUID:z.uuid()现在更严格地遵循 RFC 9562/4122 标准;如果需要更宽松的 8-4-4-4-12 十六进制模式,请使用z.guid()。