tRPC input / output
pnpm add @trpc/server @trpc/client zod
// server/routers/user.ts import { z } from "zod"; import { router, publicProcedure } from "../trpc"; const CreateUserInput = z.object({ email: z.string().email(), name: z.string().min(1).max(50), }); const User = z.object({ id: z.string().uuid(), email: z.string(), name: z.string(), createdAt: z.date(), }); export const userRouter = router({ create: publicProcedure .input(CreateUserInput) // ← 入参 schema .output(User) // ← 出参 schema .mutation(async ({ input }) => { // input 已类型安全 & 校验完毕 return await db.user.create({ data: input }); }), byId: publicProcedure .input(z.object({ id: z.string().uuid() })) .output(User) .query(async ({ input }) => { return await db.user.findUnique({ where: { id: input.id } }); }), });
// client const user = await trpc.user.create.mutate({ email: "a@b.com", name: "Alice", }); // user 类型自动从 output schema 推断,无需手写
tRPC 的 input 在服务端跑——客户端传错直接 400;output 只在 dev 模式跑——防止后端返回意外结构。schema 即文档,调用方 IDE 提示精准。
Hono + zValidator
pnpm add hono @hono/zod-validator zod
import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; const app = new Hono(); const CreatePost = z.object({ title: z.string().min(1), content: z.string(), tags: z.array(z.string()).max(5), }); app.post( "/posts", zValidator("json", CreatePost), async (c) => { const data = c.req.valid("json"); // 类型安全 const post = await createPost(data); return c.json(post); } ); // query / param / form 都支持 const ListQuery = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().max(100).default(20), }); app.get("/posts", zValidator("query", ListQuery), (c) => { const { page, limit } = c.req.valid("query"); // page 和 limit 是 number,coerce 自动把 "1" → 1 });
Hono RPC 模式
// server 侧暴露类型 export type AppType = typeof app; // client 侧(同仓 monorepo) import { hc } from "hono/client"; import type { AppType } from "./server"; const client = hc<AppType>("http://localhost:3000"); const res = await client.posts.$post({ json: { title: "hi", content: "...", tags: [] }, }); // 入参 / 出参类型从 Zod schema 贯穿到客户端
Hono RPC 模式让 Hono 变成"轻量版 tRPC"——不需要 codegen,不需要额外协议,只用 TypeScript 类型导出。
错误响应的自定义
app.post( "/posts", zValidator("json", CreatePost, (result, c) => { if (!result.success) { return c.json( { error: "validation", issues: result.error.flatten().fieldErrors }, 400 ); } }), handler );
zod-to-openapi:自动生成 API 文档
pnpm add @asteasolutions/zod-to-openapi
import { extendZodWithOpenApi, OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import { z } from "zod"; extendZodWithOpenApi(z); // 给 z 加 .openapi() 方法 const registry = new OpenAPIRegistry(); const User = z .object({ id: z.string().uuid().openapi({ example: "550e8400-..." }), email: z.string().email().openapi({ example: "a@b.com" }), name: z.string().openapi({ example: "Alice" }), }) .openapi("User"); // ← 注册为 components.schemas.User registry.registerPath({ method: "post", path: "/users", summary: "创建用户", request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email(), name: z.string() }), }, }, }, }, responses: { 200: { description: "OK", content: { "application/json": { schema: User }, }, }, }, }); const generator = new OpenApiGeneratorV3(registry.definitions); const openApiDoc = generator.generateDocument({ openapi: "3.0.0", info: { title: "My API", version: "1.0.0" }, }); // 把 openApiDoc 塞给 Swagger UI 或 Scalar
配合 Scalar 或 Swagger UI,直接生成可交互文档——前端同学不用再催"你的新字段是啥类型"。
Hono + zod-openapi 一体化
pnpm add @hono/zod-openapi @scalar/hono-api-reference
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import { apiReference } from "@scalar/hono-api-reference"; const app = new OpenAPIHono(); const route = createRoute({ method: "get", path: "/users/{id}", request: { params: z.object({ id: z.string().uuid() }), }, responses: { 200: { content: { "application/json": { schema: User }, }, description: "get one user", }, }, }); app.openapi(route, (c) => { const { id } = c.req.valid("param"); return c.json({ id, email: "a@b.com", name: "A" }); }); // 自动挂载文档 app.doc("/openapi.json", { openapi: "3.0.0", info: { title: "API", version: "1" }, }); app.get("/docs", apiReference({ spec: { url: "/openapi.json" } }));
访问 /docs 直接看到漂亮的 Scalar 文档,改 schema 自动同步。
Express + 手动校验(最朴素)
import express from "express"; import { z } from "zod"; const BodySchema = z.object({ ... }); function validate<T extends z.ZodTypeAny>(schema: T) { return (req, res, next) => { const result = schema.safeParse(req.body); if (!result.success) { return res.status(400).json({ error: "Validation", issues: result.error.flatten().fieldErrors, }); } req.body = result.data; // 解析后的数据(含 transform) next(); }; } app.post("/users", validate(BodySchema), (req, res) => { // req.body 是 safeParse 后的值 });
响应校验(防止后端越权返回)
const PublicUser = UserRow.omit({ password: true }); function sendUser(res, user) { res.json(PublicUser.parse(user)); // 不含 password 的版本 // 如果 user.password 还在,parse 会把它 strip 掉 // 如果以后加了 password 变成必填,还会报错提醒我们处理 }
"严进严出"——入口 Zod 拒绝脏数据,出口 Zod 剥敏感字段。防止哪天 ORM 返回了额外字段(如 stripe_customer_id)误泄漏到前端。
Better-T Stack / Next.js App Router 示例
// app/api/posts/route.ts import { z } from "zod"; import { NextResponse } from "next/server"; const CreatePost = z.object({ title: z.string(), content: z.string() }); export async function POST(req: Request) { const res = CreatePost.safeParse(await req.json()); if (!res.success) { return NextResponse.json( { issues: res.error.flatten().fieldErrors }, { status: 400 } ); } const post = await createPost(res.data); return NextResponse.json(post); }
Drizzle ORM + drizzle-zod
pnpm add drizzle-zod
import { users } from "./schema"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; const InsertUser = createInsertSchema(users); // 从 DB schema 生成 const SelectUser = createSelectSchema(users); // 可以继续 .extend 加业务约束 const CreateUser = InsertUser.extend({ email: z.string().email(), password: z.string().min(8), });
DB 表结构是第一真源,Zod schema 从 Drizzle 反生。改表自动同步——再也不会"DB 加了字段但 Zod schema 忘了加"。
LLM 结构化输出(OpenAI / Anthropic)
import { openai } from "@ai-sdk/openai"; import { generateObject } from "ai"; import { z } from "zod"; const Recipe = z.object({ name: z.string(), ingredients: z.array(z.object({ name: z.string(), amount: z.string(), })), steps: z.array(z.string()), }); const { object: recipe } = await generateObject({ model: openai("gpt-4o"), schema: Recipe, // ← Zod schema 直接喂给 LLM prompt: "给我一份番茄炒蛋食谱", }); // recipe 是 Recipe 类型,且保证符合 schema // OpenAI 用 schema 约束 JSON mode,输出必然合法
Vercel AI SDK / OpenAI SDK / Anthropic SDK 都原生吃 Zod——LLM 输出 JSON 不再靠 JSON.parse 碰运气。
文件上传(multipart)
const UploadSchema = z.object({ file: z.instanceof(File).refine((f) => f.size < 10 * 1024 * 1024), description: z.string().optional(), }); // Hono app.post("/upload", zValidator("form", UploadSchema), async (c) => { const { file, description } = c.req.valid("form"); await saveToS3(file); });
WebSocket / SSE 消息校验
const Message = z.discriminatedUnion("type", [ z.object({ type: z.literal("chat"), text: z.string() }), z.object({ type: z.literal("typing"), userId: z.string() }), z.object({ type: z.literal("ack"), id: z.string() }), ]); ws.onmessage = (e) => { const res = Message.safeParse(JSON.parse(e.data)); if (!res.success) return; // 未知消息忽略 switch (res.data.type) { case "chat": showChat(res.data.text); break; case "typing": showTyping(res.data.userId); break; case "ack": handleAck(res.data.id); break; } };
本章小结
- tRPC 把 Zod schema 当 RPC 入参/出参——客户端类型端到端贯穿
- Hono + zValidator 一行中间件,RPC 模式等于"轻量 tRPC"
- zod-to-openapi / @hono/zod-openapi 从 schema 自动吐 Swagger
- drizzle-zod 从 DB 反推 schema,表是第一真源
- LLM 结构化输出吃 Zod schema——JSON 解析不再碰运气