Skip to content

Zod 运行时校验深入

一句话概述:Schema 定义、类型推导、错误处理、与 React Hook Form / tRPC / Next.js 集成——实现编译期与运行时的类型统一

为什么需要运行时校验?

TypeScript 的类型只在编译期存在,运行时被完全擦除。这意味着:

typescript
interface User {
  id: string;
  name: string;
  age: number;
}

// 编译期:完美
async function getUser(): Promise<User> {
  const res = await fetch('/api/user');
  return res.json(); // 返回 any,被断言为 User
}

// 运行时:如果 API 返回 { id: 123, name: null }
// TypeScript 毫无察觉,bug 会延迟到使用时才暴露

Zod 解决的核心问题:在一处定义 Schema,同时获得编译期类型和运行时校验,消除类型与实际数据的不一致。


一、Zod 基础

安装

bash
npm install zod

Zod 是零依赖库,自带 TypeScript 类型,无需 @types 包。

基本 Schema 定义

typescript
import { z } from 'zod';

// 原始类型
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const undefinedSchema = z.undefined();
const nullSchema = z.null();

// 校验
stringSchema.parse('hello');  // ✅ 返回 "hello"
stringSchema.parse(42);       // ❌ 抛出 ZodError

// 安全校验(不抛异常)
const result = stringSchema.safeParse(42);
if (result.success) {
  console.log(result.data); // string
} else {
  console.log(result.error); // ZodError
}

对象 Schema

typescript
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'user', 'guest']),
  bio: z.string().optional(),         // string | undefined
  avatar: z.string().url().nullable(), // string | null
  createdAt: z.coerce.date(),          // 自动将字符串/数字转为 Date
});

// 从 Schema 推导 TypeScript 类型
type User = z.infer<typeof UserSchema>;
// {
//   id: string;
//   name: string;
//   email: string;
//   age: number;
//   role: "admin" | "user" | "guest";
//   bio?: string | undefined;
//   avatar: string | null;
//   createdAt: Date;
// }

数组和元组

typescript
// 数组
const TagsSchema = z.array(z.string()).min(1).max(10);
type Tags = z.infer<typeof TagsSchema>; // string[]

// 元组
const CoordSchema = z.tuple([z.number(), z.number()]);
type Coord = z.infer<typeof CoordSchema>; // [number, number]

// 带 rest 的元组
const ArgsSchema = z.tuple([z.string()]).rest(z.number());
type Args = z.infer<typeof ArgsSchema>; // [string, ...number[]]

联合类型和可辨识联合

typescript
// 联合类型
const StringOrNumber = z.union([z.string(), z.number()]);
// 简写
const StringOrNumber2 = z.string().or(z.number());

// 可辨识联合(Discriminated Union)—— 性能更好
const ShapeSchema = z.discriminatedUnion('kind', [
  z.object({ kind: z.literal('circle'), radius: z.number() }),
  z.object({ kind: z.literal('rect'), width: z.number(), height: z.number() }),
]);

type Shape = z.infer<typeof ShapeSchema>;
// { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }

二、Schema 变换与组合

对象方法

typescript
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
  password: z.string(),
});

// pick:选取字段
const PublicUserSchema = UserSchema.pick({ id: true, name: true });
type PublicUser = z.infer<typeof PublicUserSchema>;
// { id: string; name: string }

// omit:排除字段
const UserWithoutPasswordSchema = UserSchema.omit({ password: true });

// partial:所有字段可选
const UpdateUserSchema = UserSchema.partial();
// { id?: string; name?: string; email?: string; password?: string }

// required:所有字段必选
const FullUserSchema = UpdateUserSchema.required();

// extend:扩展字段
const UserWithAgeSchema = UserSchema.extend({
  age: z.number(),
});

// merge:合并两个 Schema
const AddressSchema = z.object({ city: z.string(), zip: z.string() });
const UserWithAddressSchema = UserSchema.merge(AddressSchema);

// passthrough / strip / strict
const loose = UserSchema.passthrough(); // 保留未知字段
const strict = UserSchema.strict();      // 拒绝未知字段(默认 strip 去除)

transform:数据转换

typescript
// 校验后转换数据
const NumberFromString = z.string().transform((val) => parseInt(val, 10));
type R = z.infer<typeof NumberFromString>; // number

// 输入类型 vs 输出类型
type Input = z.input<typeof NumberFromString>;  // string
type Output = z.output<typeof NumberFromString>; // number

// 实际场景:API 返回日期字符串 → 转为 Date
const DateString = z.string().datetime().transform((s) => new Date(s));

// 复杂转换:格式化用户数据
const RawUserSchema = z.object({
  first_name: z.string(),
  last_name: z.string(),
  email_address: z.string().email(),
}).transform((raw) => ({
  firstName: raw.first_name,
  lastName: raw.last_name,
  email: raw.email_address,
  fullName: `${raw.first_name} ${raw.last_name}`,
}));

type RawUser = z.input<typeof RawUserSchema>;
// { first_name: string; last_name: string; email_address: string }

type User = z.output<typeof RawUserSchema>;
// { firstName: string; lastName: string; email: string; fullName: string }

refinesuperRefine:自定义校验

typescript
// refine:简单自定义校验
const PasswordSchema = z.string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), {
    message: '密码必须包含大写字母',
  })
  .refine((val) => /[0-9]/.test(val), {
    message: '密码必须包含数字',
  });

// superRefine:复杂校验(可添加多个错误)
const RegisterSchema = 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: '两次密码不一致',
      path: ['confirmPassword'],
    });
  }
});

pipe:链式校验

typescript
// 先校验再转换再校验
const ProcessedNumber = z.string()
  .transform((val) => parseInt(val, 10))
  .pipe(z.number().min(0).max(100));

ProcessedNumber.parse('42');   // ✅ 返回 42
ProcessedNumber.parse('200');  // ❌ 超出范围
ProcessedNumber.parse('abc');  // ❌ NaN 不是 number

三、错误处理

ZodError 结构

typescript
const UserSchema = z.object({
  name: z.string().min(1),
  age: z.number().int().positive(),
  email: z.string().email(),
});

const result = UserSchema.safeParse({
  name: '',
  age: -1,
  email: 'not-email',
});

if (!result.success) {
  console.log(result.error.issues);
  // [
  //   { code: 'too_small', path: ['name'], message: 'String must contain at least 1 character(s)' },
  //   { code: 'too_small', path: ['age'], message: 'Number must be greater than 0' },
  //   { code: 'invalid_string', path: ['email'], message: 'Invalid email' },
  // ]

  // 格式化为字段 → 错误信息的映射
  console.log(result.error.flatten());
  // {
  //   formErrors: [],
  //   fieldErrors: {
  //     name: ['String must contain at least 1 character(s)'],
  //     age: ['Number must be greater than 0'],
  //     email: ['Invalid email'],
  //   }
  // }
}

自定义错误消息

typescript
const UserSchema = z.object({
  name: z.string({
    required_error: '姓名不能为空',
    invalid_type_error: '姓名必须是字符串',
  }).min(1, { message: '姓名至少1个字符' }),

  age: z.number({
    required_error: '年龄不能为空',
  }).int('年龄必须是整数').positive('年龄必须为正数'),
});

国际化(i18n)

typescript
// 全局自定义错误映射
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === 'string') {
      return { message: `至少需要 ${issue.minimum} 个字符` };
    }
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

四、与框架集成

React Hook Form + Zod

typescript
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const LoginSchema = z.object({
  email: z.string().email('请输入有效的邮箱'),
  password: z.string().min(8, '密码至少8位'),
  remember: z.boolean().default(false),
});

type LoginForm = z.infer<typeof LoginSchema>;

function LoginPage() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginForm>({
    resolver: zodResolver(LoginSchema),
  });

  const onSubmit = (data: LoginForm) => {
    // data 已经过校验,类型安全
    console.log(data.email, data.password);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <label>
        <input type="checkbox" {...register('remember')} />
        记住我
      </label>

      <button type="submit">登录</button>
    </form>
  );
}

tRPC + Zod(端到端类型安全)

typescript
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

const CreateUserInput = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

export const userRouter = router({
  create: publicProcedure
    .input(CreateUserInput) // 用 Zod Schema 定义输入
    .mutation(async ({ input }) => {
      // input 自动推断为 { name: string; email: string }
      const user = await db.user.create({ data: input });
      return user;
    }),

  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input.id } });
    }),
});

// client 端(React)
function CreateUser() {
  const mutation = trpc.user.create.useMutation();

  const handleSubmit = () => {
    mutation.mutate({
      name: 'Alice',
      email: 'alice@example.com',
      // 这里有完整的类型提示和校验
    });
  };
}

Next.js Server Actions + Zod

typescript
// app/actions.ts
'use server';

import { z } from 'zod';

const ContactSchema = z.object({
  name: z.string().min(1, '姓名不能为空'),
  email: z.string().email('邮箱格式不正确'),
  message: z.string().min(10, '消息至少10个字符'),
});

export async function submitContact(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = ContactSchema.safeParse(rawData);

  if (!result.success) {
    return { success: false, errors: result.error.flatten().fieldErrors };
  }

  // result.data 是类型安全的
  await db.contact.create({ data: result.data });
  return { success: true, errors: null };
}

API 响应校验

typescript
import { z } from 'zod';

// 定义 API 响应 Schema
const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
  z.object({
    code: z.number(),
    data: dataSchema,
    message: z.string(),
  });

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

const UserListResponseSchema = ApiResponseSchema(z.array(UserSchema));

// 类型安全的 fetch 封装
async function fetchUsers(): Promise<z.infer<typeof UserListResponseSchema>> {
  const res = await fetch('/api/users');
  const json = await res.json();
  return UserListResponseSchema.parse(json); // 运行时校验
}

五、高级技巧

Branded Types

typescript
const UserId = z.string().uuid().brand('UserId');
type UserId = z.infer<typeof UserId>; // string & BRAND<"UserId">

const OrderId = z.string().uuid().brand('OrderId');
type OrderId = z.infer<typeof OrderId>;

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

const userId = UserId.parse('550e8400-e29b-41d4-a716-446655440000');
const orderId = OrderId.parse('550e8400-e29b-41d4-a716-446655440001');

getUser(userId);  // ✅
// getUser(orderId); // ❌ 类型不兼容

递归 Schema

typescript
// 树形结构
interface TreeNode {
  value: string;
  children: TreeNode[];
}

const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
  z.object({
    value: z.string(),
    children: z.array(TreeNodeSchema),
  })
);

// JSON 值
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
    z.null(),
    z.array(JsonValueSchema),
    z.record(JsonValueSchema),
  ])
);

从已有类型创建 Schema(z.ZodType<T>

typescript
// 当你已经有 TypeScript 类型,想确保 Schema 匹配
interface User {
  id: string;
  name: string;
  age: number;
}

// 确保 Schema 的输出类型匹配 User
const UserSchema: z.ZodType<User> = z.object({
  id: z.string(),
  name: z.string(),
  age: z.number(),
});
// 如果 Schema 的输出类型和 User 不匹配,编译报错

面试高频题

1. 为什么需要 Zod?TypeScript 类型不够吗?

答案:TypeScript 类型在编译后被完全擦除,无法在运行时校验数据。API 返回值、用户输入、URL 参数等外部数据在运行时可能与预期类型不符。Zod 通过"Schema 即类型"的模式,在一处定义同时获得编译期类型(z.infer)和运行时校验(.parse),实现端到端类型安全。

2. z.infer 是怎么实现的?

答案z.infer<T> 是一个条件类型,从 Zod Schema 实例上提取 _output 类型。每个 Zod Schema 内部维护了 _input_output 两个类型参数,z.infer_outputz.input_input。当使用 transform 时两者可能不同。

3. parsesafeParse 的区别?

答案parse 校验失败时抛出 ZodError 异常,成功时返回校验后的数据。safeParse 不抛异常,返回 { success: true, data }{ success: false, error } 的联合类型。推荐在可预期的校验场景用 safeParse,在"不应该出错"的场景(如内部数据)用 parse

4. Zod 如何与 React Hook Form 配合?

答案:通过 @hookform/resolvers/zod 包的 zodResolver 适配器。将 Zod Schema 传入 useForm({ resolver: zodResolver(schema) }),React Hook Form 会自动使用 Zod 进行校验,错误信息自动映射到 formState.errors。Schema 的 z.infer 类型直接作为表单数据类型,实现定义一次类型和校验都有。