DevToolBox免费
博客

Zod 验证指南:Schema、转换、精细化与 tRPC 集成

12 分钟作者 DevToolBox

Zod 是一个 TypeScript 优先的 schema 验证库,让你定义数据的 schema 并在运行时验证,同时提供完整的 TypeScript 类型推断。定义一次,同时获得运行时验证和编译时类型安全。

为什么选择 Zod?

传统 TypeScript 类型仅在编译时存在。来自外部源的数据在运行时从不被验证。Zod 通过提供自动生成 TypeScript 类型的运行时验证来弥合这一差距。

主要优势

  • 零依赖:没有外部包的小型库
  • TypeScript 优先:完整的类型推断
  • 可组合:schema 可以组合、扩展和转换
  • 不可变:所有方法返回新的 schema 实例
  • 详细错误:带路径和自定义消息的丰富错误信息
  • 到处运行:Node.js、Deno、Bun、浏览器

基本 Schema

Zod 为所有 JavaScript 类型提供原语,以及组合成复杂 schema 的方法。

import { z } from "zod";

// Primitive types
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const undefinedSchema = z.undefined();
const nullSchema = z.null();
const bigintSchema = z.bigint();

// String validations
const emailSchema = z.string().email("Invalid email address");
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const minSchema = z.string().min(3, "Too short");
const maxSchema = z.string().max(100, "Too long");
const regexSchema = z.string().regex(/^[A-Z]{2}\d{4}$/, "Invalid format");
const trimSchema = z.string().trim();  // trims whitespace

// Number validations
const positiveSchema = z.number().positive();
const intSchema = z.number().int();
const rangeSchema = z.number().min(0).max(100);
const finiteSchema = z.number().finite();

// Enums
const roleSchema = z.enum(["admin", "user", "guest"]);
type Role = z.infer<typeof roleSchema>;  // "admin" | "user" | "guest"

// Literals
const statusSchema = z.literal("active");

// Unions
const idSchema = z.union([z.string(), z.number()]);
// Shorthand: z.string().or(z.number())

// Parsing
const result = emailSchema.safeParse("test@example.com");
if (result.success) {
  console.log(result.data);  // "test@example.com"
} else {
  console.log(result.error.issues);
}

对象 Schema

对象 schema 是最常见的类型,验证 JavaScript 对象的形状并为每个属性提供类型推断。

// Object schemas with full type inference
const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(["admin", "user", "guest"]).default("user"),
  tags: z.array(z.string()).default([]),
  address: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string().regex(/^\d{5}(-\d{4})?$/),
    country: z.string().length(2),
  }).optional(),
});

// Infer the TypeScript type automatically
type User = z.infer<typeof UserSchema>;
// {
//   id: number;
//   name: string;
//   email: string;
//   age?: number | undefined;
//   role: "admin" | "user" | "guest";
//   tags: string[];
//   address?: { street: string; city: string; zip: string; country: string } | undefined;
// }

// Schema composition
const CreateUserSchema = UserSchema.omit({ id: true });
const UpdateUserSchema = UserSchema.partial().required({ id: true });
const PublicUserSchema = UserSchema.pick({ id: true, name: true, role: true });

// Extending schemas
const AdminSchema = UserSchema.extend({
  permissions: z.array(z.string()),
  department: z.string(),
});

// Merging schemas
const WithTimestamps = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});
const UserWithTimestamps = UserSchema.merge(WithTimestamps);

转换和细化

转换修改输出类型,细化添加自定义验证逻辑。两者对实际验证都必不可少。

自定义细化

refine 方法添加超越类型检查的自定义验证规则。

// Custom refinements
const PasswordSchema = z.string()
  .min(8, "Password must be at least 8 characters")
  .refine(val => /[A-Z]/.test(val), "Must contain uppercase letter")
  .refine(val => /[a-z]/.test(val), "Must contain lowercase letter")
  .refine(val => /[0-9]/.test(val), "Must contain number")
  .refine(val => /[^A-Za-z0-9]/.test(val), "Must contain special character");

// Super refinement for cross-field validation
const SignupSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Passwords do not match",
      path: ["confirmPassword"],
    });
  }
});

// Date range validation
const DateRangeSchema = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).refine(
  data => data.endDate > data.startDate,
  { message: "End date must be after start date", path: ["endDate"] }
);

数据转换

transform 方法改变 schema 的输出类型,用于解析字符串为数字、规范化数据等。

// Transform: string to number
const NumericStringSchema = z.string().transform(Number);
// Input: "42" -> Output: 42

// Transform: normalize data
const NormalizedEmailSchema = z.string()
  .email()
  .transform(email => email.toLowerCase().trim());

// Transform with validation
const PositiveIntString = z.string()
  .transform(Number)
  .pipe(z.number().int().positive());
// Input: "42" -> validates as string -> transforms to 42 -> validates as positive int

// Transform: parse JSON strings
const JsonSchema = z.string().transform((str, ctx) => {
  try {
    return JSON.parse(str);
  } catch {
    ctx.addIssue({ code: "custom", message: "Invalid JSON" });
    return z.NEVER;
  }
});

// Coercion (built-in transforms)
const coercedNumber = z.coerce.number();  // "42" -> 42
const coercedBoolean = z.coerce.boolean(); // "true" -> true
const coercedDate = z.coerce.date();       // "2026-01-01" -> Date

实际模式

这些模式在生产 TypeScript 应用中常用。

API 响应验证

验证 API 响应以捕获破坏性变更。

// Validate API responses
const ApiResponseSchema = z.object({
  data: z.array(UserSchema),
  pagination: z.object({
    page: z.number(),
    perPage: z.number(),
    total: z.number(),
    totalPages: z.number(),
  }),
});

type ApiResponse = z.infer<typeof ApiResponseSchema>;

async function fetchUsers(page: number): Promise<ApiResponse> {
  const res = await fetch(`/api/users?page=${page}`);
  const json = await res.json();

  // Validate response - throws if invalid
  return ApiResponseSchema.parse(json);
}

// Safe version that returns errors
async function fetchUsersSafe(page: number) {
  const res = await fetch(`/api/users?page=${page}`);
  const json = await res.json();
  return ApiResponseSchema.safeParse(json);
}

环境变量验证

在应用启动时验证环境变量。

// Validate environment variables at startup
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  REDIS_URL: z.string().url().optional(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  CORS_ORIGINS: z.string().transform(s => s.split(",")),
});

// Parse and export typed env
const env = EnvSchema.parse(process.env);

export default env;
// env.PORT is number (not string!)
// env.CORS_ORIGINS is string[]
// env.NODE_ENV is "development" | "production" | "test"

高级特性

可辨识联合

可辨识联合基于共享的辨识字段验证对象。

// Discriminated union - efficient, checks "type" field first
const EventSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("click"),
    x: z.number(),
    y: z.number(),
    target: z.string(),
  }),
  z.object({
    type: z.literal("keypress"),
    key: z.string(),
    modifiers: z.array(z.enum(["ctrl", "shift", "alt", "meta"])),
  }),
  z.object({
    type: z.literal("scroll"),
    deltaX: z.number(),
    deltaY: z.number(),
  }),
]);

type Event = z.infer<typeof EventSchema>;

function handleEvent(event: Event) {
  switch (event.type) {
    case "click":
      console.log(`Click at (${event.x}, ${event.y})`);  // TS knows x, y exist
      break;
    case "keypress":
      console.log(`Key: ${event.key}`);  // TS knows key exists
      break;
    case "scroll":
      console.log(`Scroll: ${event.deltaY}`);
      break;
  }
}

品牌类型

品牌类型创建名义类型值,防止混合结构相同的类型。

// Branded types prevent mixing structurally identical types
const UserId = z.string().uuid().brand<"UserId">();
const PostId = z.string().uuid().brand<"PostId">();

type UserId = z.infer<typeof UserId>;  // string & { __brand: "UserId" }
type PostId = z.infer<typeof PostId>;  // string & { __brand: "PostId" }

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = UserId.parse("550e8400-e29b-41d4-a716-446655440000");
const postId = PostId.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8");

getUser(userId);  // OK
// getUser(postId); // TypeScript ERROR! PostId is not UserId

错误处理

Zod 提供可以格式化为用户友好消息的详细错误信息。

// Handling validation errors
const result = UserSchema.safeParse(invalidData);

if (!result.success) {
  // Zod error has structured issues
  const errors = result.error.issues;
  // [
  //   { code: "too_small", path: ["name"], message: "Too short" },
  //   { code: "invalid_string", path: ["email"], message: "Invalid email" }
  // ]

  // Format errors by field
  const fieldErrors = result.error.flatten().fieldErrors;
  // { name: ["Too short"], email: ["Invalid email"] }

  // Format as a single message
  const formatted = result.error.format();
  // { name: { _errors: ["Too short"] }, email: { _errors: ["Invalid email"] } }
}

// Custom error map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small) {
    return { message: `Must be at least ${issue.minimum} characters` };
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

框架集成

tRPC

tRPC 使用 Zod schema 进行输入验证,提供端到端类型安全。

// tRPC with Zod: end-to-end type safety
import { initTRPC } from "@trpc/server";

const t = initTRPC.create();

const appRouter = t.router({
  getUser: t.procedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      // input is typed as { id: string }
      return await db.user.findUnique({ where: { id: input.id } });
    }),

  createUser: t.procedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      // input is typed as { name: string; email: string }
      return await db.user.create({ data: input });
    }),
});

// Client gets full type inference from the schemas!
// const user = await trpc.getUser.query({ id: "..." });

常见问题

Zod 与 Yup 相比如何?

Zod 是 TypeScript 优先的,有更好的类型推断。对于新的 TypeScript 项目,Zod 是更好的选择。

Zod 影响运行时性能吗?

Zod 设计为快速的。简单 schema 验证只需微秒。建议只在系统边界验证。

可以不用 TypeScript 使用 Zod 吗?

可以,但会失去自动类型推断的主要优势。JavaScript 项目可以考虑 Joi 或 Yup。

如何验证嵌套对象?

在 z.object() 中嵌套 z.object()。使用 z.lazy() 处理递归结构。

Zod 能取代 TypeScript 接口吗?

对于跨系统边界的数据可以。对于内部应用类型,普通 TypeScript 接口更简单。

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

ZDJSON to Zod Schema🔷JSON to TypeScript Online{ }JSON FormatterY→YAML to JSON Converter

相关文章

TypeScript 5 新特性:装饰器、const 类型参数与 satisfies 运算符

TypeScript 5 新特性完全指南:装饰器、const 类型参数、satisfies 运算符及性能改进。

TypeScript 类型守卫:运行时类型检查完全指南

掌握 TypeScript 类型守卫:typeof、instanceof、in、自定义类型守卫和可辨识联合。

GraphQL vs REST API:2026 年该用哪个?

深入比较 GraphQL 和 REST API,附代码示例。学习架构差异、数据获取模式、缓存策略,以及何时选择哪种方案。

JSON 转 Zod Schema:TypeScript 中的类型安全运行时验证

学习如何将 JSON 转换为 Zod schema,实现 TypeScript 中的类型安全运行时验证。涵盖基本类型、对象、数组、联合类型、z.infer 以及与 JSON Schema 的对比。