Zod 校验集成

本示例展示如何使用 Zod 替换 VextJS 内置的 schema-dsl 校验引擎。通过编写一个 Zod 校验插件,你可以在路由的 validate 配置中直接使用 Zod schema,获得更强大的类型推断和校验能力。

为什么选择 Zod?

特性内置 schema-dslZod
学习曲线低(DSL 字符串语法)中(链式 API)
TypeScript 类型推断❌ 无(需手动声明泛型)✅ 自动推断 z.infer<T>
复杂校验基础类型 + 范围 + 枚举联合类型、递归、transform、refine 等
生态VextJS 专属广泛使用,社区生态丰富
包体积0(框架内置)~14KB (min+gzip)
Tip

如果你的项目只需要简单的参数校验(字符串/数字/枚举/邮箱等),内置的 schema-dsl 已经足够。Zod 更适合需要复杂校验逻辑、深度类型推断或已在项目中使用 Zod 的场景。

项目结构

zod-validation/
  ├── src/
  │   ├── config/
  │   │   └── default.ts
  │   ├── plugins/
  │   │   └── zod-validator.ts     ← Zod 校验插件
  │   ├── routes/
  │   │   ├── index.ts
  │   │   └── users.ts
  │   ├── services/
  │   │   └── user.ts
  │   ├── schemas/                  ← Zod schema 定义
  │   │   └── user.ts
  │   └── index.ts
  ├── package.json
  └── tsconfig.json

1. 安装依赖

npx vextjs create zod-validation
cd zod-validation
pnpm add zod

2. Zod 校验插件

核心思路:通过 app.setValidator() 替换内置的 schema-dsl 校验引擎。VextValidator 接口要求实现 compile(schema) 方法,返回一个校验函数。

// src/plugins/zod-validator.ts
import { definePlugin } from "vextjs";
import { ZodType, ZodError } from "zod";

/**
 * Zod 校验插件
 *
 * 替换内置 schema-dsl 校验引擎,使路由 validate 配置支持 Zod schema。
 *
 * 使用方式:
 *   validate: {
 *     body: userCreateSchema,   // 直接传入 Zod schema 对象
 *     query: paginationSchema,
 *   }
 *
 * 校验流程:
 *   1. compile(schema) 接收原始 schema 对象
 *   2. 检查 schema 的每个字段是否为 ZodType 实例
 *   3. 如果是 Zod schema,使用 safeParse 进行校验
 *   4. 如果不是(普通对象),回退到原始校验器(兼容 schema-dsl)
 */
export default definePlugin({
  name: "zod-validator",
  setup(app) {
    // 保存原始校验器,用于回退
    const originalValidator = app.getValidator();

    app.setValidator({
      compile(schema: Record<string, unknown>) {
        // ── 判断 schema 是否为 Zod schema ──────────────────
        //
        // 路由 validate 的每个位置(query/body/param/header)都是一个独立的 schema。
        // 如果传入的是 ZodType 实例,使用 Zod 校验。
        // 否则回退到原始 schema-dsl 校验器(保持向后兼容)。

        if (schema instanceof ZodType) {
          // ── Zod 校验路径 ─────────────────────────────────
          const zodSchema = schema;

          return (data: unknown) => {
            const result = zodSchema.safeParse(data);

            if (result.success) {
              return {
                valid: true,
                data: result.data,
              };
            }

            // 转换 Zod 错误格式为 VextJS 标准格式
            return {
              valid: false,
              errors: result.error.issues.map((issue) => ({
                field: issue.path.join("."),
                message: issue.message,
              })),
            };
          };
        }

        // ── 检查 schema 对象的字段是否包含 Zod 实例 ─────────
        //
        // 支持 validate: { body: { name: z.string(), age: z.number() } }
        // 这种「对象字段为 Zod」的混合模式。
        // 但更推荐直接传入完整的 z.object({...}) 作为 schema。

        const hasZodFields = Object.values(schema).some(
          (v) => v instanceof ZodType,
        );

        if (hasZodFields) {
          // 将散落的 Zod 字段收集为 z.object
          const { z } = require("zod");
          const shape: Record<string, ZodType> = {};

          for (const [key, value] of Object.entries(schema)) {
            if (value instanceof ZodType) {
              shape[key] = value;
            } else {
              // 非 Zod 字段,包装为 z.any()
              shape[key] = z.any();
            }
          }

          const objectSchema = z.object(shape);

          return (data: unknown) => {
            const result = objectSchema.safeParse(data);

            if (result.success) {
              return {
                valid: true,
                data: result.data,
              };
            }

            return {
              valid: false,
              errors: result.error.issues.map((issue) => ({
                field: issue.path.join("."),
                message: issue.message,
              })),
            };
          };
        }

        // ── 回退到原始 schema-dsl 校验器 ───────────────────
        return originalValidator.compile(schema);
      },
    });

    app.logger.info("[zod-validator] Zod 校验插件已注册");
  },
});
Tip

这个插件向后兼容 schema-dsl。你可以在同一个项目中混合使用 Zod schema 和 schema-dsl DSL 字符串。对于非 Zod 的 schema(如 { name: 'string:1-50' }),插件会自动回退到内置校验引擎。

3. 定义 Zod Schema

将 Zod schema 集中管理在 src/schemas/ 目录下,方便复用和维护。

// src/schemas/user.ts
import { z } from "zod";

// ── 基础字段 schema ──────────────────────────────────────

/** 用户 ID(路径参数) */
export const userIdParam = z.object({
  id: z.string().min(1, "ID 不能为空"),
});

/** 分页查询参数 */
export const paginationQuery = z.object({
  page: z.coerce.number().int().min(1, "页码最小值为 1").default(1),
  limit: z.coerce.number().int().min(1).max(100, "每页最多 100 条").default(10),
  keyword: z.string().optional(),
});

// ── 用户 CRUD schema ────────────────────────────────────

/** 创建用户 */
export const createUserBody = z.object({
  name: z.string().min(1, "姓名不能为空").max(50, "姓名最长 50 个字符").trim(),
  email: z.string().email("邮箱格式无效").toLowerCase(),
  age: z
    .number()
    .int("年龄必须为整数")
    .min(0, "年龄不能为负数")
    .max(200, "年龄不能超过 200")
    .optional(),
  role: z
    .enum(["admin", "user", "editor"], {
      errorMap: () => ({ message: "角色必须为 admin、user 或 editor" }),
    })
    .default("user"),
  tags: z.array(z.string().min(1).max(20)).max(10, "最多 10 个标签").optional(),
  profile: z
    .object({
      bio: z.string().max(500, "简介最长 500 个字符").optional(),
      avatar: z.string().url("头像必须为有效 URL").optional(),
      website: z.string().url("网站必须为有效 URL").optional(),
    })
    .optional(),
});

/** 更新用户(所有字段可选) */
export const updateUserBody = createUserBody
  .partial() // 所有字段变为可选
  .omit({ role: true }) // 不允许通过更新接口修改角色
  .refine((data) => Object.keys(data).length > 0, {
    message: "至少需要提供一个要更新的字段",
  });

/** 批量操作 */
export const batchDeleteBody = z.object({
  ids: z
    .array(z.string().min(1))
    .min(1, "至少选择一个用户")
    .max(50, "单次最多删除 50 个"),
});

// ── 导出推断类型 ─────────────────────────────────────────

/** 从 Zod schema 推断 TypeScript 类型 */
export type CreateUserInput = z.infer<typeof createUserBody>;
export type UpdateUserInput = z.infer<typeof updateUserBody>;
export type PaginationInput = z.infer<typeof paginationQuery>;
export type BatchDeleteInput = z.infer<typeof batchDeleteBody>;
Tip

z.coerce.number() 是 Zod 的类型强制转换,会自动将字符串 '10' 转换为数字 10。这对于查询参数(始终为字符串)特别有用,与 schema-dsl 的自动类型转换行为一致。

4. 配置

// src/config/default.ts
export default {
  port: 3000,
  adapter: "native",
  logger: {
    level: "debug",
    pretty: true,
  },
  cors: {
    enabled: true,
    origins: ["*"],
  },
  response: {
    wrap: true,
    hideInternalErrors: false,
  },
  openapi: {
    enabled: true,
    title: "Zod Validation 示例",
    version: "1.0.0",
    description: "使用 Zod 进行参数校验的 VextJS 示例",
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
      },
    },
    guardSecurityMap: {
      auth: "bearerAuth",
    },
  },
  middlewares: [{ name: "auth" }],
};

5. 路由(使用 Zod Schema)

// src/routes/users.ts
import { defineRoutes } from "vextjs";
import {
  userIdParam,
  paginationQuery,
  createUserBody,
  updateUserBody,
  batchDeleteBody,
} from "../schemas/user.js";
import type { CreateUserInput, UpdateUserInput } from "../schemas/user.js";

export default defineRoutes((app) => {
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  // GET /users/list — 分页查询
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  app.get(
    "/list",
    {
      validate: {
        query: paginationQuery, // ← Zod schema
      },
      docs: {
        summary: "用户列表",
        description: "分页查询用户列表,支持按姓名或邮箱模糊搜索。",
        tags: ["用户"],
      },
    },
    async (req, res) => {
      // req.valid() 返回的数据已经过 Zod 校验和 transform
      // page/limit 已从字符串自动转换为数字(z.coerce.number())
      // keyword 为 string | undefined
      const { page, limit, keyword } = req.valid("query");

      const result = await app.services.user.findAll({ page, limit, keyword });
      res.json(result);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  // GET /users/:id — 查询详情
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  app.get(
    "/:id",
    {
      validate: {
        param: userIdParam, // ← Zod schema
      },
      docs: {
        summary: "获取用户详情",
        tags: ["用户"],
        responses: {
          200: { description: "查询成功" },
          404: { description: "用户不存在" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const user = await app.services.user.findById(id);

      if (!user) {
        app.throw(404, "用户不存在");
      }

      res.json(user);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  // POST /users — 创建用户
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  app.post(
    "/",
    {
      validate: {
        body: createUserBody, // ← Zod schema
      },
      middlewares: ["auth"],
      docs: {
        summary: "创建用户",
        description: "创建新用户。支持标签和个人资料等复杂嵌套结构。",
        tags: ["用户"],
        responses: {
          201: {
            description: "创建成功",
            example: {
              id: "4",
              name: "Diana",
              email: "diana@example.com",
              role: "user",
              tags: ["developer"],
              profile: { bio: "Hello!" },
            },
          },
          400: { description: "参数校验失败" },
          401: { description: "未认证" },
          409: { description: "邮箱已注册" },
        },
      },
    },
    async (req, res) => {
      // Zod 已自动:
      //   - trim() 了 name
      //   - toLowerCase() 了 email
      //   - 设置了 role 默认值 'user'
      //   - 校验了 tags 数组长度和元素格式
      //   - 校验了 profile 嵌套对象的 URL 格式
      const body = req.valid<CreateUserInput>("body");

      const user = await app.services.user.create(body);
      res.json(user, 201);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  // PUT /users/:id — 更新用户
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  app.put(
    "/:id",
    {
      validate: {
        param: userIdParam,
        body: updateUserBody, // ← partial() + refine()
      },
      middlewares: ["auth"],
      docs: {
        summary: "更新用户",
        description:
          "更新用户信息。所有字段可选,但至少需要提供一个字段。不允许修改角色。",
        tags: ["用户"],
        responses: {
          200: { description: "更新成功" },
          400: { description: "参数校验失败(或未提供任何字段)" },
          401: { description: "未认证" },
          404: { description: "用户不存在" },
          409: { description: "邮箱已被其他用户使用" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      // updateUserBody 使用了 .partial().omit({ role: true }).refine(...)
      // Zod 确保了至少有一个字段,且不包含 role
      const body = req.valid<UpdateUserInput>("body");

      const user = await app.services.user.update(id, body);
      res.json(user);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  // DELETE /users/:id — 删除用户
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  app.delete(
    "/:id",
    {
      validate: {
        param: userIdParam,
      },
      middlewares: ["auth"],
      docs: {
        summary: "删除用户",
        tags: ["用户"],
        responses: {
          204: { description: "删除成功" },
          401: { description: "未认证" },
          404: { description: "用户不存在" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      await app.services.user.delete(id);
      res.status(204).json(null);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  // POST /users/batch-delete — 批量删除
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  app.post(
    "/batch-delete",
    {
      validate: {
        body: batchDeleteBody, // ← 数组校验
      },
      middlewares: ["auth"],
      docs: {
        summary: "批量删除用户",
        description: "批量删除多个用户。单次最多 50 个。",
        tags: ["用户"],
        responses: {
          200: { description: "批量删除结果" },
          400: { description: "参数校验失败" },
          401: { description: "未认证" },
        },
      },
    },
    async (req, res) => {
      const { ids } = req.valid("body");

      const results = {
        deleted: [] as string[],
        notFound: [] as string[],
      };

      for (const id of ids) {
        try {
          await app.services.user.delete(id);
          results.deleted.push(id);
        } catch {
          results.notFound.push(id);
        }
      }

      res.json(results);
    },
  );
});

6. 服务层

// src/services/user.ts
import type { VextApp, VextLogger } from "vextjs";
import type { CreateUserInput, UpdateUserInput } from "../schemas/user.js";

interface User {
  id: string;
  name: string;
  email: string;
  age?: number;
  role: string;
  tags?: string[];
  profile?: {
    bio?: string;
    avatar?: string;
    website?: string;
  };
  createdAt: string;
  updatedAt: string;
}

export default class UserService {
  private logger: VextLogger;
  private users: Map<string, User> = new Map();
  private nextId = 1;

  constructor(private app: VextApp) {
    this.logger = app.logger.child({ service: "UserService" });
    this.seed();
  }

  private seed(): void {
    const seedUsers: Partial<User>[] = [
      {
        name: "Alice",
        email: "alice@example.com",
        age: 28,
        role: "admin",
        tags: ["管理员"],
      },
      { name: "Bob", email: "bob@example.com", age: 32, role: "user" },
      {
        name: "Charlie",
        email: "charlie@example.com",
        role: "editor",
        profile: { bio: "编辑" },
      },
    ];

    for (const u of seedUsers) {
      const id = String(this.nextId++);
      const now = new Date().toISOString();
      this.users.set(id, { id, ...u, createdAt: now, updatedAt: now } as User);
    }
  }

  async findAll(options: { page: number; limit: number; keyword?: string }) {
    let allUsers = Array.from(this.users.values());

    if (options.keyword) {
      const kw = options.keyword.toLowerCase();
      allUsers = allUsers.filter(
        (u) =>
          u.name.toLowerCase().includes(kw) ||
          u.email.toLowerCase().includes(kw),
      );
    }

    const total = allUsers.length;
    const start = (options.page - 1) * options.limit;
    const items = allUsers.slice(start, start + options.limit);

    return {
      items,
      total,
      page: options.page,
      limit: options.limit,
      totalPages: Math.ceil(total / options.limit),
    };
  }

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }

  async create(data: CreateUserInput): Promise<User> {
    // 邮箱唯一性检查
    for (const user of this.users.values()) {
      if (user.email === data.email) {
        this.app.throw(409, "邮箱已注册", 10001);
      }
    }

    const id = String(this.nextId++);
    const now = new Date().toISOString();

    const user: User = {
      id,
      name: data.name,
      email: data.email,
      age: data.age,
      role: data.role,
      tags: data.tags,
      profile: data.profile,
      createdAt: now,
      updatedAt: now,
    };

    this.users.set(id, user);
    this.logger.info({ userId: id, email: data.email }, "用户创建成功");
    return user;
  }

  async update(id: string, data: UpdateUserInput): Promise<User> {
    const user = this.users.get(id);
    if (!user) {
      this.app.throw(404, "用户不存在");
    }

    if (data.email && data.email !== user.email) {
      for (const u of this.users.values()) {
        if (u.email === data.email) {
          this.app.throw(409, "邮箱已被其他用户使用", 10002);
        }
      }
    }

    const updated: User = {
      ...user,
      ...data,
      // 深度合并 profile
      profile: data.profile
        ? { ...user.profile, ...data.profile }
        : user.profile,
      updatedAt: new Date().toISOString(),
    };

    this.users.set(id, updated);
    return updated;
  }

  async delete(id: string): Promise<void> {
    if (!this.users.has(id)) {
      this.app.throw(404, "用户不存在");
    }
    this.users.delete(id);
  }
}

7. 入口文件

// src/index.ts
import { bootstrap } from "vextjs";

bootstrap().catch((err) => {
  console.error("启动失败:", err);
  process.exit(1);
});

8. 运行与验证

# 启动开发服务器
pnpm dev

测试校验

# ✅ 正常创建(Zod 自动 trim name + toLowerCase email)
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{
    "name": "  Diana  ",
    "email": "Diana@EXAMPLE.COM",
    "age": 25,
    "tags": ["developer", "designer"],
    "profile": { "bio": "全栈开发者", "website": "https://diana.dev" }
  }'
# → 201
# name 被 trim 为 "Diana"
# email 被 toLowerCase 为 "diana@example.com"
# role 默认设为 "user"

# ❌ 嵌套对象校验 — profile.avatar 非 URL
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{
    "name": "Eve",
    "email": "eve@example.com",
    "profile": { "avatar": "not-a-url" }
  }'
# → 400 {"errors":[{"field":"profile.avatar","message":"头像必须为有效 URL"}]}

# ❌ 数组长度校验 — 超过 10 个标签
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{
    "name": "Eve",
    "email": "eve@example.com",
    "tags": ["1","2","3","4","5","6","7","8","9","10","11"]
  }'
# → 400 {"errors":[{"field":"tags","message":"最多 10 个标签"}]}

# ❌ refine 校验 — 更新时未提供任何字段
curl -X PUT http://localhost:3000/users/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{}'
# → 400 {"errors":[{"field":"","message":"至少需要提供一个要更新的字段"}]}

# ❌ omit 校验 — 更新时尝试修改角色(被 omit 移除)
curl -X PUT http://localhost:3000/users/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"role": "admin", "name": "Alice Updated"}'
# → 200 但 role 字段被忽略(Zod strip 未知字段),只有 name 被更新

# ❌ 枚举校验 — 无效的角色值
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"name":"Frank","email":"frank@example.com","role":"superadmin"}'
# → 400 {"errors":[{"field":"role","message":"角色必须为 admin、user 或 editor"}]}

# ✅ 查询参数自动类型转换(z.coerce.number)
curl "http://localhost:3000/users/list?page=2&limit=5"
# → page 自动从字符串 "2" 转为数字 2

# ✅ 批量删除
curl -X POST http://localhost:3000/users/batch-delete \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"ids": ["1", "2", "999"]}'
# → {"code":0,"data":{"deleted":["1","2"],"notFound":["999"]},...}

Zod 高级技巧

Transform(数据转换)

import { z } from "zod";

// 查询参数:逗号分隔字符串 → 数组
const searchQuery = z.object({
  ids: z
    .string()
    .transform((val) => val.split(",").filter(Boolean))
    .pipe(z.array(z.string().min(1)).min(1))
    .optional(),
  sort: z
    .string()
    .regex(/^[a-zA-Z]+:(asc|desc)$/, "排序格式:field:asc 或 field:desc")
    .transform((val) => {
      const [field, order] = val.split(":");
      return { field, order: order as "asc" | "desc" };
    })
    .optional(),
});

// 使用
app.get("/search", { validate: { query: searchQuery } }, async (req, res) => {
  const { ids, sort } = req.valid("query");
  // ids: string[] | undefined
  // sort: { field: string, order: 'asc' | 'desc' } | undefined
});

Discriminated Union(区分联合类型)

import { z } from "zod";

// 根据 type 字段使用不同的校验规则
const notificationBody = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("email"),
    to: z.string().email(),
    subject: z.string().min(1).max(200),
    body: z.string().min(1),
  }),
  z.object({
    type: z.literal("sms"),
    phone: z.string().regex(/^\+?[\d\s-]{10,15}$/),
    message: z.string().min(1).max(160),
  }),
  z.object({
    type: z.literal("push"),
    deviceToken: z.string().min(1),
    title: z.string().min(1).max(100),
    body: z.string().min(1).max(500),
  }),
]);

app.post("/notify", { validate: { body: notificationBody } }, handler);

Preprocess(预处理)

import { z } from "zod";

// 处理 "true" / "false" 字符串到布尔值的转换(常见于查询参数)
const filterQuery = z
  .object({
    active: z.preprocess((val) => {
      if (val === "true") return true;
      if (val === "false") return false;
      return val;
    }, z.boolean().optional()),
    minAge: z.coerce.number().int().min(0).optional(),
    maxAge: z.coerce.number().int().max(200).optional(),
  })
  .refine(
    (data) => {
      if (data.minAge !== undefined && data.maxAge !== undefined) {
        return data.minAge <= data.maxAge;
      }
      return true;
    },
    { message: "minAge 必须小于等于 maxAge", path: ["minAge"] },
  );

复用与组合

import { z } from "zod";

// 基础 schema
const addressSchema = z.object({
  street: z.string().min(1).max(200),
  city: z.string().min(1).max(50),
  state: z.string().min(1).max(50),
  zip: z.string().regex(/^\d{6}$/, "邮编必须为 6 位数字"),
  country: z.string().min(1).max(50).default("中国"),
});

// 组合使用
const orderSchema = z.object({
  items: z
    .array(
      z.object({
        productId: z.string().uuid(),
        quantity: z.number().int().min(1).max(999),
        price: z.number().positive(),
      }),
    )
    .min(1, "至少需要一个商品"),
  shippingAddress: addressSchema,
  billingAddress: addressSchema.optional(), // 可选,复用同一个 schema
  note: z.string().max(500).optional(),
});

// 从已有 schema 推断类型
type Order = z.infer<typeof orderSchema>;
type Address = z.infer<typeof addressSchema>;

与 schema-dsl 混合使用

Zod 插件支持在同一个项目中同时使用 Zod 和 schema-dsl:

// 这个路由使用 Zod
app.post(
  "/users",
  {
    validate: {
      body: createUserBody, // ← Zod schema
      query: paginationQuery, // ← Zod schema
    },
  },
  handler,
);

// 这个路由使用 schema-dsl(自动回退)
app.get(
  "/health",
  {
    validate: {
      query: {
        verbose: "boolean?", // ← schema-dsl 字符串
      },
    },
  },
  handler,
);

插件内部会自动检测 schema 类型,Zod 实例走 Zod 校验路径,普通对象走 schema-dsl 路径。

关键对比

场景schema-dslZod
简单字段校验name: 'string:1-50'z.string().min(1).max(50)
可选字段age: 'number?'z.number().optional()
枚举role: 'enum:admin,user'z.enum(['admin', 'user'])
邮箱email: 'email'z.string().email()
嵌套对象❌ 不支持z.object({ profile: z.object({...}) })
数组校验tags: 'array'z.array(z.string()).max(10)
联合类型❌ 不支持z.union([...]) / z.discriminatedUnion(...)
自定义校验❌ 不支持.refine() / .superRefine()
数据转换仅类型转换.transform() / .preprocess()
默认值❌ 不支持.default()
类型推断❌ 需手动声明z.infer<typeof schema>

下一步