Zod 运行时校验深入
一句话概述:Schema 定义、类型推导、错误处理、与 React Hook Form / tRPC / Next.js 集成——实现编译期与运行时的类型统一
为什么需要运行时校验?
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 基础
安装
npm install zodZod 是零依赖库,自带 TypeScript 类型,无需 @types 包。
基本 Schema 定义
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
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;
// }数组和元组
// 数组
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[]]联合类型和可辨识联合
// 联合类型
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 变换与组合
对象方法
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:数据转换
// 校验后转换数据
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 }refine 和 superRefine:自定义校验
// 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:链式校验
// 先校验再转换再校验
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 结构
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'],
// }
// }
}自定义错误消息
const UserSchema = z.object({
name: z.string({
required_error: '姓名不能为空',
invalid_type_error: '姓名必须是字符串',
}).min(1, { message: '姓名至少1个字符' }),
age: z.number({
required_error: '年龄不能为空',
}).int('年龄必须是整数').positive('年龄必须为正数'),
});国际化(i18n)
// 全局自定义错误映射
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
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(端到端类型安全)
// 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
// 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 响应校验
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
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
// 树形结构
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 类型,想确保 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 取 _output,z.input 取 _input。当使用 transform 时两者可能不同。
3. parse 和 safeParse 的区别?
答案: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 类型直接作为表单数据类型,实现定义一次类型和校验都有。