Chapter 08

schema 就是 API 契约

HTTP 层既是最容易出 bug 的地方,也是最该用 Zod 的地方。tRPC 把 Zod schema 当 RPC 方法签名,Hono 用 zValidator 做一行校验中间件,zod-to-openapi 从 schema 自动吐 Swagger JSON——一份 schema,三份产出。

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;
  }
};

本章小结