Chapter 02

schema 就是类型的灵魂

掌握 Zod 最基本的三件事:用 z.xxx() 定义 schema、用 parse/safeParse 校验数据、用 z.infer 反推 TS 类型。这三步几乎覆盖 80% 的日常使用。

安装

pnpm add zod
# Zod v4 已是默认版本(~2025 起)
import { z } from "zod";

原始类型

z.string();              // string
z.number();              // number
z.bigint();              // bigint
z.boolean();             // boolean
z.date();                // Date 实例(不是日期字符串!)
z.symbol();              // symbol

z.undefined();
z.null();
z.void();                // 通常用于函数返回
z.any();
z.unknown();
z.never();

parse:抛异常式

const result = z.string().parse("hello");
// → "hello"(原样返回,TS 类型 string)

z.string().parse(123);
// 抛 ZodError:
// Expected string, received number

适合"数据就应该对,对不了就让它崩"的场景——启动时 env 校验、Server Action 输入等。

safeParse:Result 式

const res = z.string().safeParse(123);

if (res.success) {
  console.log(res.data);      // string
} else {
  console.error(res.error);    // ZodError 实例
}

适合表单校验、API 入口——错了不 crash,返回错误给用户。

选择原则
知道数据"应该对"(如启动 env、代码里自己造的对象)—— parse;数据来自外部用户/网络—— safeParse。不要用 try/catch(parse) 当控制流。

z.object

const User = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(50),
  age: z.number().int().min(0).max(150),
  email: z.string().email(),
});

const u = User.parse({
  id: "550e8400-e29b-41d4-a716-446655440000",
  name: "Alice",
  age: 30,
  email: "a@b.com",
});

z.infer:类型推断

type User = z.infer<typeof User>;
// 等价于:
// type User = {
//   id: string;
//   name: string;
//   age: number;
//   email: string;
// }

一份 schema 同时是值和类型——不用再手写 interface。改 schema 就自动改类型,绝不漏。

z.array

z.array(z.string());
// string[]

z.string().array();    // 语法糖,同上

z.array(z.number()).min(1).max(10);
// number[] 且长度 1-10

z.array(User);
// User[]

嵌套对象

const Post = z.object({
  id: z.string(),
  title: z.string(),
  author: z.object({
    id: z.string(),
    name: z.string(),
  }),
  tags: z.array(z.string()),
  comments: z.array(
    z.object({
      user: z.string(),
      text: z.string(),
      createdAt: z.date(),
    })
  ),
});

可选字段

const User = z.object({
  name: z.string(),
  bio: z.string().optional(),        // string | undefined
  avatar: z.string().nullable(),      // string | null
  signupDate: z.date().nullish(),     // Date | null | undefined
});

默认值

const Settings = z.object({
  theme: z.enum(["light", "dark"]).default("light"),
  fontSize: z.number().default(14),
});

Settings.parse({});
// { theme: "light", fontSize: 14 } ← 填入默认

default 的值参与解析结果——输出类型依然含这些字段,而不是 undefined。

字符串修饰

z.string()
  .min(3)
  .max(20)
  .length(10)          // 精确长度
  .email()
  .url()
  .uuid()
  .cuid()
  .regex(/^[a-z]+$/)
  .startsWith("https://")
  .endsWith(".com")
  .includes("foo")
  .trim()            // 先 trim 再校验
  .toLowerCase()
  .nonempty();       // 非空串,等价于 .min(1)

数字修饰

z.number()
  .int()
  .positive()
  .nonnegative()
  .negative()
  .multipleOf(5)
  .min(0)
  .max(100)
  .gt(0).gte(0)   // 同 min 但排他
  .lt(100).lte(100)
  .finite()
  .safe();              // Number.MAX_SAFE_INTEGER 内

z.enum

const Role = z.enum(["admin", "editor", "viewer"]);
type Role = z.infer<typeof Role>;
// "admin" | "editor" | "viewer"

Role.options;      // ["admin", "editor", "viewer"] ← 运行时也能拿到
Role.enum;         // { admin: "admin", editor: "editor", viewer: "viewer" }

z.union([z.literal("admin"), ...]) 清爽,还能枚举 options。

native enum 集成

enum Role { Admin = "admin", User = "user" }

const schema = z.nativeEnum(Role);
// 接受 "admin" / "user"

z.literal

z.literal("foo");           // "foo" 字面量
z.literal(42);
z.literal(true);

coerce:强制类型转换

z.coerce.string();
// 先 String(input),再当字符串校验

z.coerce.number().parse("42");
// → 42(number)

z.coerce.boolean().parse("true");
// → true,但 "false" 也是 true!(Boolean("false") === true)

z.coerce.date().parse("2026-05-01");
// → Date 实例
coerce.boolean 的陷阱
任何非空字符串都被 Boolean(...) 转成 true——包括 "false"、"0"。如果要"字符串 'true' → true",用 z.enum(["true", "false"]).transform(s => s === "true")

strict / passthrough / strip

const User = z.object({ name: z.string() });

// strip(默认):未知字段会被丢弃
User.parse({ name: "A", extra: 1 });
// → { name: "A" }

// strict:未知字段报错
User.strict().parse({ name: "A", extra: 1 });
// → ZodError: Unrecognized key: "extra"

// passthrough:未知字段保留
User.passthrough().parse({ name: "A", extra: 1 });
// → { name: "A", extra: 1 }

// catchall:未知字段都得符合某 schema
User.catchall(z.number()).parse({ name: "A", extra: 1 });
// 额外字段必须是 number

shape 和 keyof

User.shape.name;
// 拿到 name 字段的子 schema

User.keyof();
// z.enum(["id", "name", "age", "email"])
// 常用于生成"字段选择器"的下拉

手动 issue(提前 return)

const schema = z.string().refine(
  (s) => s.startsWith("http"),
  { message: "必须以 http 开头" }
);

const res = schema.safeParse("ftp://...");
if (!res.success) {
  console.log(res.error.issues);
  // [{ code: "custom", message: "必须以 http 开头", path: [] }]
}

parseAsync(异步校验)

const schema = z.string().refine(
  async (s) => !await isBlocked(s),   // 查 DB
  "用户名已被占用"
);

const u = await schema.parseAsync("alice");
// 含异步 refine 的 schema 要用 parseAsync / safeParseAsync

describe:文档化

z.string()
  .email()
  .describe("用户登录邮箱,必须能收通知");

// 给 zod-to-openapi 生成文档时用

一个完整例子

const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(200),
  price: z.number().nonnegative().multipleOf(0.01),
  currency: z.enum(["CNY", "USD", "EUR"]).default("CNY"),
  stock: z.number().int().nonnegative(),
  tags: z.array(z.string()).max(10).default([]),
  description: z.string().optional(),
  publishedAt: z.coerce.date().nullable(),
});

type Product = z.infer<typeof ProductSchema>;
// {
//   id: string;
//   name: string;
//   price: number;
//   currency: "CNY" | "USD" | "EUR";
//   stock: number;
//   tags: string[];
//   description?: string;
//   publishedAt: Date | null;
// }

本章小结