1. Zod.js 简介
什么是 Zod?
Zod 是一个以 TypeScript 为首的 schema 声明和验证库。 [1][2] 这意味着你首先定义一个数据的“形状”或“蓝图”(即 schema),然后 Zod 可以根据这个 schema 来验证实际数据是否符合预期。它不仅仅是做类型检查,更重要的是它在运行时进行数据验证。 [3][4]
为什么在前端使用 Zod?
在前端开发中,我们经常处理来自各种来源的数据,例如:
这些数据在到达你的 JavaScript/TypeScript 代码时,其结构和类型往往是不确定的。TypeScript 的静态类型检查在编译时非常有用,但它无法保证运行时的外部数据符合你的类型定义。 [3][4] Zod 弥补了这一空缺,它允许你:
- 确保数据可靠性:在数据进入你的核心业务逻辑之前,确保其结构和内容是正确的。
- 提供清晰的错误信息:当数据验证失败时,Zod 提供详细的错误报告,方便调试和向用户反馈。 [3][9]
- 与 TypeScript 完美集成:Zod 的 schema 可以自动推断出相应的 TypeScript 类型,这意味着你不需要为同一份数据结构维护两套定义(一套 Zod schema,一套 TypeScript 类型)。 [3][10] 这极大地提高了开发效率并减少了错误。
- 代码即文档:Zod schema 本身就是对数据结构的一种清晰描述。 [4]
- 零依赖:Zod 本身非常轻量,没有外部依赖。 [2][10]
2. 安装与基本使用
安装 Zod
你可以使用 npm, yarn, 或 pnpm 来安装 Zod:
# npm
npm install zod
# yarn
yarn add zod
# pnpm
pnpm add zod
环境要求:
- TypeScript 4.5+ (官方推荐,旧版本可能可用但不受支持) [1][11]
- 在
tsconfig.json中启用strict模式,这是 TypeScript 项目的最佳实践。 [1][12]
// tsconfig.json
{
"compilerOptions": {
"strict": true
// ... 其他配置
}
}
第一个 Zod schema
让我们从一个简单的例子开始,定义一个字符串类型的 schema:
// 导入 z 对象,它是 Zod 库的入口点
import { z } from 'zod';
// 创建一个字符串 schema
const myStringSchema = z.string();
// 使用 schema 来验证数据
try {
const validString = myStringSchema.parse("你好,Zod!"); // ✓ 验证通过,返回 "你好,Zod!"
console.log("验证通过:", validString);
const invalidData = myStringSchema.parse(123); // ❌ 验证失败,抛出 ZodError
console.log("这里不会执行");
} catch (error) {
console.error("验证失败:", error.errors); // error 是一个 ZodError 实例
}
[4][10]
在上面的例子中,myStringSchema.parse("你好,Zod!") 会成功并返回原始字符串。而 myStringSchema.parse(123) 会抛出一个 ZodError,因为数字 123 不符合字符串 schema。
parse vs safeParse
Zod 提供了两种主要的方式来执行验证:
-
schema.parse(data): 如果验证成功,它会返回经过验证(可能也经过转换)的数据。如果验证失败,它会抛出一个ZodError异常。 [10][13] -
schema.safeParse(data): 这个方法不会抛出异常。它总是返回一个包含success字段的对象。 [10][13]- 如果验证成功,返回
{ success: true, data: validatedData }。 - 如果验证失败,返回
{ success: false, error: ZodError }。
- 如果验证成功,返回
在前端,尤其是在处理用户输入或 API 响应时,safeParse 通常是更推荐的选择,因为它允许你更优雅地处理错误,而不需要 try...catch 块。 [14][15]
import { z } from 'zod';
const ageSchema = z.number().positive("年龄必须是正数");
// 使用 safeParse
const result1 = ageSchema.safeParse(25);
if (result1.success) {
console.log("年龄验证成功:", result1.data); // result1.data 是 25
} else {
console.error("年龄验证失败:", result1.error.flatten().fieldErrors);
}
const result2 = ageSchema.safeParse(-5);
if (result2.success) {
console.log("年龄验证成功:", result2.data);
} else {
console.error("年龄验证失败:", result2.error.flatten().fieldErrors);
// 输出: { _errors: [ '年龄必须是正数' ] } (取决于具体错误和 Zod 版本)
// 或者更常见的是针对字段的错误,如果ageSchema是对象的一部分
// 对于原始类型,错误可能在 _errors 或直接在 formErrors
}
const result3 = ageSchema.safeParse("not a number");
if (result3.success) {
console.log("类型验证成功:", result3.data);
} else {
console.error("类型验证失败:", result3.error.flatten().formErrors);
// 输出: [ 'Expected number, received string' ]
}
3. 核心 Schema 类型详解
Zod 提供了丰富的 schema 类型来定义各种数据结构。
原始类型 (Primitives) [11]
-
z.string(): 验证数据是否为字符串。-
链式校验方法:
.min(length, message?): 最小长度。 [12][16].max(length, message?): 最大长度。.length(length, message?): 固定长度。.email(message?): 验证是否为有效的 email 格式。 [12][16].url(message?): 验证是否为有效的 URL 格式。.uuid(message?): 验证是否为有效的 UUID 格式。.cuid(message?): 验证是否为有效的 CUID 格式。.cuid2(message?): 验证是否为有效的 CUID2 格式。.ulid(message?): 验证是否为有效的 ULID 格式。.regex(regex, message?): 使用正则表达式验证。.startsWith(value, message?): 验证字符串是否以特定值开头。.endsWith(value, message?): 验证字符串是否以特定值结尾。.trim(): (转换) 移除字符串两端的空白字符。.toLowerCase(): (转换) 将字符串转为小写。.toUpperCase(): (转换) 将字符串转为大写。.nonempty(message?): 确保字符串不是空的 (等同于.min(1))。 [14].datetime(options?): 验证 ISO 8601 日期时间字符串。.ip(options?): 验证 IP 地址 (v4 或 v6)。
import { z } from 'zod'; const usernameSchema = z.string() .min(3, { message: "用户名至少需要3个字符" }) .max(20, { message: "用户名不能超过20个字符" }) .regex(/^[a-zA-Z0-9_]+$/, { message: "用户名只能包含字母、数字和下划线" }); const emailSchema = z.string().email({ message: "无效的邮箱地址" }); console.log(usernameSchema.safeParse("ab")); // { success: false, error: ... } console.log(usernameSchema.safeParse("test_user_123")); // { success: true, data: "test_user_123" } console.log(emailSchema.safeParse("test@example.com")); // { success: true, data: "test@example.com" } -
-
z.number(): 验证数据是否为数字。-
链式校验方法:
.gt(value, message?): 大于value。.gte(value, message?)或.min(value, message?): 大于等于value。.lt(value, message?): 小于value。.lte(value, message?)或.max(value, message?): 小于等于value。.int(message?): 验证是否为整数。 [12][16].positive(message?): 验证是否为正数 (>0)。 [12][16].nonnegative(message?): 验证是否为非负数 (>=0)。.negative(message?): 验证是否为负数 (<0)。.nonpositive(message?): 验证是否为非正数 (<=0)。.multipleOf(value, message?)或.step(value, message?): 验证是否为value的倍数。.finite(message?): 验证是否为有限数 (不是Infinity或-Infinity)。.safe(message?): 验证是否为安全整数 (在Number.MIN_SAFE_INTEGER和Number.MAX_SAFE_INTEGER之间)。
import { z } from 'zod'; const ageSchema = z.number() .int({ message: "年龄必须是整数" }) .positive({ message: "年龄必须是正数" }) .gte(18, { message: "必须年满18岁" }); console.log(ageSchema.safeParse(30)); // { success: true, data: 30 } console.log(ageSchema.safeParse(17.5)); // { success: false, error: ... } -
-
z.bigint(): 验证数据是否为 BigInt。 -
z.boolean(): 验证数据是否为布尔值。 [12] -
z.date(): 验证数据是否为 JavaScriptDate对象。.min(date, message?): 最小日期。.max(date, message?): 最大日期。
-
z.symbol(): 验证数据是否为 Symbol。 -
z.undefined(): 验证数据是否为undefined。 -
z.null(): 验证数据是否为null。 [12] -
z.void(): 验证函数没有返回值 (即返回undefined)。通常用于函数返回类型的 schema。 -
z.any(): 不进行任何验证,允许任何类型。应谨慎使用,因为它会破坏 Zod 带来的类型安全。 -
z.unknown(): 不进行任何验证,允许任何类型,但推荐使用,因为在访问数据前需要先进行类型收窄。 -
z.never(): 不允许任何值。如果数据被解析到这个 schema,会抛出错误。import { z } from 'zod'; const isActiveSchema = z.boolean(); const creationDateSchema = z.date(); const maybeNullSchema = z.null(); const anyValueSchema = z.any(); // 尽量避免 const unknownValueSchema = z.unknown(); // 更安全的选择 console.log(isActiveSchema.parse(true)); console.log(creationDateSchema.parse(new Date())); console.log(maybeNullSchema.parse(null)); // unknown 需要进一步检查 const unknownData = unknownValueSchema.parse({ a: 1 }); if (typeof unknownData === 'object' && unknownData !== null && 'a' in unknownData) { // 现在可以安全地访问 unknownData.a (需要类型断言或更复杂的检查) }
字面量 (Literals)
-
z.literal(value): 验证数据是否严格等于某个字面量值 (字符串、数字、布尔值)。import { z } from 'zod'; const statusSchema = z.literal("success"); const versionSchema = z.literal(1); const exactBooleanSchema = z.literal(true); console.log(statusSchema.parse("success")); // "success" // statusSchema.parse("error"); // 抛出 ZodError // 通常与 union 结合使用 const httpMethodSchema = z.union([ z.literal("GET"), z.literal("POST"), z.literal("PUT"), z.literal("DELETE"), ]); type HttpMethod = z.infer<typeof httpMethodSchema>; // "GET" | "POST" | "PUT" | "DELETE" console.log(httpMethodSchema.parse("POST"));
枚举 (Enums)
-
z.enum([option1, option2, ...]): 验证数据是否为预定义字符串列表中的一个。这对于字符串枚举非常有用。 [13]import { z } from 'zod'; const UserRoleSchema = z.enum(["ADMIN", "USER", "GUEST"]); type UserRole = z.infer<typeof UserRoleSchema>; // "ADMIN" | "USER" | "GUEST" console.log(UserRoleSchema.parse("ADMIN")); // UserRoleSchema.parse("EDITOR"); // 抛出 ZodError -
z.nativeEnum(nativeEnumObject): 用于验证 TypeScript 的原生enum。import { z } from 'zod'; enum Color { Red = "RED", Green = "GREEN", Blue = "BLUE", } const ColorSchema = z.nativeEnum(Color); type ColorType = z.infer<typeof ColorSchema>; // Color (原生枚举类型) console.log(ColorSchema.parse(Color.Red)); // "RED" console.log(ColorSchema.parse("GREEN")); // "GREEN" // ColorSchema.parse("YELLOW"); // 抛出 ZodError enum NumericEnum { Zero, // 0 One, // 1 } const NumericEnumSchema = z.nativeEnum(NumericEnum); console.log(NumericEnumSchema.parse(0)); // 0 console.log(NumericEnumSchema.parse(NumericEnum.One)); // 1
对象 (Objects)
-
z.object({ key: valueSchema, ... }): 验证数据是否为具有特定属性和对应 schema 的对象。 [3][12]import { z } from 'zod'; const UserSchema = z.object({ id: z.string().uuid(), username: z.string().min(3), email: z.string().email(), age: z.number().positive().optional(), // age 是可选的 isActive: z.boolean().default(true), // isActive 有默认值 true address: z.object({ // 嵌套对象 street: z.string(), city: z.string(), zipCode: z.string().regex(/^\d{5}(-\d{4})?$/), // 美国邮编格式 }).optional(), }); type User = z.infer<typeof UserSchema>; /* 推断出的类型 User: { id: string; username: string; email: string; age?: number | undefined; isActive: boolean; address?: { street: string; city: string; zipCode: string; } | undefined; } */ const userData = { id: "a1b2c3d4-e5f6-7890-1234-567890abcdef", username: "john_doe", email: "john.doe@example.com", // age 和 address 未提供,因为它们是可选的 }; const validationResult = UserSchema.safeParse(userData); if (validationResult.success) { console.log("用户数据有效:", validationResult.data); // validationResult.data.isActive 将会是 true (因为有 default) } else { console.error("用户数据无效:", validationResult.error.flatten()); } -
对象方法和修饰符:
.shape: 获取定义对象形状的对象。UserSchema.shape.username会给你username的 schema。.keyof(): 创建一个由对象键组成的枚举 schema。UserSchema.keyof()会是z.enum(['id', 'username', 'email', 'age', 'isActive', 'address'])。.extend({ newKey: newSchema, ... }): 扩展现有对象 schema,添加新的字段。 [16].merge(otherObjectSchema): 合并两个对象 schema。.pick({ keyToPick: true, ... }): 从现有 schema 中选择一部分键来创建新的对象 schema。.omit({ keyToOmit: true, ... }): 从现有 schema 中排除一部分键来创建新的对象 schema。.partial(): 使对象中的所有字段都变为可选。可以传入参数指定哪些字段变为可选UserSchema.partial({ username: true })。.deepPartial(): 递归地使对象及其嵌套对象中的所有字段都变为可选。.required(): 使对象中的所有字段都变为必需。可以传入参数指定哪些字段变为必需。.passthrough(): 默认情况下,Zod 会去除对象中未在 schema 中定义的额外字段。使用.passthrough()会保留这些额外字段。.strict(message?): 如果对象包含未在 schema 中定义的额外字段,则抛出错误。.strip(): (默认行为) 去除对象中未在 schema 中定义的额外字段。.catchall(valueSchema): 为对象中所有未明确定义的键指定一个 schema。这对于动态键很有用。
import { z } from 'zod'; const BaseUserSchema = z.object({ id: z.string().uuid(), name: z.string(), }); // .extend() const AdminUserSchema = BaseUserSchema.extend({ permissions: z.array(z.string()), }); type AdminUser = z.infer<typeof AdminUserSchema>; // { id: string; name: string; permissions: string[] } // .pick() const UserNameSchema = BaseUserSchema.pick({ name: true }); type UserName = z.infer<typeof UserNameSchema>; // { name: string } // .omit() const UserWithoutIdSchema = BaseUserSchema.omit({ id: true }); type UserWithoutId = z.infer<typeof UserWithoutIdSchema>; // { name: string } // .partial() const PartialUserSchema = BaseUserSchema.partial(); type PartialUser = z.infer<typeof PartialUserSchema>; // { id?: string | undefined; name?: string | undefined; } // .passthrough() vs .strict() const StrictSchema = z.object({ a: z.string() }).strict(); const PassthroughSchema = z.object({ a: z.string() }).passthrough(); console.log(StrictSchema.safeParse({ a: "hello", b: "world" })); // success: false (因为有额外的 'b') console.log(PassthroughSchema.safeParse({ a: "hello", b: "world" })); // success: true, data: { a: "hello", b: "world" } console.log(z.object({ a: z.string() }).parse({ a: "hello", b: "world" })); // 默认 .strip(), 返回 { a: "hello" } // .catchall() const DynamicObjectSchema = z.object({ fixedProp: z.string() }).catchall(z.number()); // 其他所有属性都必须是数字 type DynamicObject = z.infer<typeof DynamicObjectSchema>; /* { fixedProp: string; [k: string]: unknown; // z.infer 对 catchall 的推断比较保守 // 实际验证时,非 fixedProp 的属性会被 catchall(z.number()) 校验 } */ console.log(DynamicObjectSchema.parse({ fixedProp: "value", dynamicKey1: 123, dynamicKey2: 456 })); // console.log(DynamicObjectSchema.safeParse({ fixedProp: "value", dynamicKey1: "not a number" })); // success: false
数组 (Arrays)
-
z.array(elementSchema): 验证数据是否为数组,并且数组中的每个元素都符合elementSchema。 [12][13]import { z } from 'zod'; const stringArraySchema = z.array(z.string()); // 字符串数组 const numberArraySchema = z.array(z.number()); // 数字数组 const userSchema = z.object({ name: z.string(), age: z.number() }); const userArraySchema = z.array(userSchema); // User 对象数组 console.log(stringArraySchema.parse(["a", "b", "c"])); console.log(userArraySchema.parse([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }])); -
数组校验方法:
.min(minLength, message?): 数组最小长度。.max(maxLength, message?): 数组最大长度。.length(fixedLength, message?): 数组固定长度。.nonempty(message?): 确保数组至少有一个元素 (等同于.min(1))。
import { z } from 'zod'; const nonEmptyTagListSchema = z.array(z.string()).nonempty({ message: "标签列表不能为空" }); const fixedSizeTupleLikeSchema = z.array(z.number()).length(3, { message: "需要恰好3个数字" }); console.log(nonEmptyTagListSchema.safeParse([])); // success: false console.log(nonEmptyTagListSchema.safeParse(["tag1"])); // success: true -
z.tuple([schemaA, schemaB, ...]): 验证数据是否为元组 (具有固定长度和特定顺序元素类型的数组)。import { z } from 'zod'; const stringNumberBooleanTuple = z.tuple([ z.string(), z.number(), z.boolean(), ]); type MyTuple = z.infer<typeof stringNumberBooleanTuple>; // [string, number, boolean] console.log(stringNumberBooleanTuple.parse(["hello", 123, true])); // stringNumberBooleanTuple.parse(["hello", 123]); // 抛出 ZodError (长度不足) // stringNumberBooleanTuple.parse(["hello", "world", true]); // 抛出 ZodError (第二个元素类型错误) // .rest() 用于元组的剩余元素 const nameAndScoresSchema = z.tuple([z.string()]).rest(z.number()); type NameAndScores = z.infer<typeof nameAndScoresSchema>; // [string, ...number[]] console.log(nameAndScoresSchema.parse(["Alice", 100, 90, 80])); console.log(nameAndScoresSchema.parse(["Bob"])); // 也有效,rest 部分可以为空
联合类型 (Unions)
-
z.union([schemaA, schemaB, ...]): 验证数据是否符合联合中至少一个 schema。import { z } from 'zod'; const stringOrNumberSchema = z.union([z.string(), z.number()]); type StringOrNumber = z.infer<typeof stringOrNumberSchema>; // string | number console.log(stringOrNumberSchema.parse("hello")); console.log(stringOrNumberSchema.parse(123)); // stringOrNumberSchema.parse(true); // 抛出 ZodError // .or() 语法糖 const stringOrNumberSchema2 = z.string().or(z.number()); // 等同于 z.union([z.string(), z.number()]) -
z.discriminatedUnion(discriminatorKey, [schemaA, schemaB, ...]): 可辨识联合类型。它基于一个共同的“辨别器”字段来确定应该使用哪个 schema 进行验证。这对于处理具有不同形状但共享某个类型字段的对象非常有用。 [17]import { z } from 'zod'; const ShapeSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("circle"), radius: z.number() }), z.object({ type: z.literal("square"), sideLength: z.number() }), z.object({ type: z.literal("triangle"), base: z.number(), height: z.number() }), ]); type Shape = z.infer<typeof ShapeSchema>; /* Shape = { type: "circle"; radius: number; } | { type: "square"; sideLength: number; } | { type: "triangle"; base: number; height: number; } */ console.log(ShapeSchema.parse({ type: "circle", radius: 5 })); console.log(ShapeSchema.parse({ type: "square", sideLength: 10 })); // ShapeSchema.parse({ type: "circle", sideLength: 10 }); // 抛出 ZodError (字段不匹配 "circle" 类型) // ShapeSchema.parse({ type: "rectangle", width: 4, height: 6 }); // 抛出 ZodError (type "rectangle" 无效)
交叉类型 (Intersections)
-
z.intersection(schemaA, schemaB): 验证数据是否同时符合schemaA和schemaB。通常用于合并对象 schema。import { z } from 'zod'; const HasIdSchema = z.object({ id: z.string() }); const HasNameSchema = z.object({ name: z.string() }); const IdentifiedNameSchema = z.intersection(HasIdSchema, HasNameSchema); // 或者使用 .and() 语法糖: const IdentifiedNameSchema = HasIdSchema.and(HasNameSchema); type IdentifiedName = z.infer<typeof IdentifiedNameSchema>; // { id: string; name: string; } console.log(IdentifiedNameSchema.parse({ id: "123", name: "Resource" })); // IdentifiedNameSchema.parse({ id: "123" }); // 抛出 ZodError (缺少 name) ``` 注意:对于原始类型的交叉(如 `z.string().and(z.number())`),结果通常是 `z.never()`,因为一个值不能同时是字符串和数字。
Record 类型
-
z.record(keySchema, valueSchema): 验证数据是否为类似字典的对象,其中所有键都符合keySchema(通常是z.string()或z.number()或z.enum()),所有值都符合valueSchema。import { z } from 'zod'; // 键是字符串,值是数字 const scoreRecordSchema = z.record(z.string(), z.number()); type ScoreRecord = z.infer<typeof scoreRecordSchema>; // { [x: string]: number; } console.log(scoreRecordSchema.parse({ math: 90, science: 85 })); // scoreRecordSchema.parse({ math: "A" }); // 抛出 ZodError (值类型错误) // 使用枚举作为键 const UserRoleEnum = z.enum(["admin", "editor", "viewer"]); const PermissionsSchema = z.record(UserRoleEnum, z.boolean()); type Permissions = z.infer<typeof PermissionsSchema>; /* type Permissions = { admin: boolean; editor: boolean; viewer: boolean; } */ console.log(PermissionsSchema.parse({ admin: true, editor: true, viewer: false }));
Map 类型
-
z.map(keySchema, valueSchema): 验证数据是否为 JavaScriptMap对象,并且其键和值分别符合keySchema和valueSchema。import { z } from 'zod'; const userMapSchema = z.map(z.string().uuid(), z.object({ name: z.string() })); type UserMap = z.infer<typeof userMapSchema>; // Map<string, { name: string; }> const myMap = new Map(); myMap.set("a1b2c3d4-e5f6-7890-1234-567890abcdef", { name: "Alice" }); console.log(userMapSchema.parse(myMap));
Set 类型
-
z.set(valueSchema): 验证数据是否为 JavaScriptSet对象,并且其所有值都符合valueSchema。import { z } from 'zod'; const stringSetSchema = z.set(z.string()); type StringSet = z.infer<typeof stringSetSchema>; // Set<string> const mySet = new Set(["apple", "banana"]); console.log(stringSetSchema.parse(mySet));
Promise 类型
-
z.promise(valueSchema): 验证数据是否为 Promise,并且该 Promise resolve 后的值符合valueSchema。import { z } from 'zod'; const stringPromiseSchema = z.promise(z.string()); type StringPromise = z.infer<typeof stringPromiseSchema>; // Promise<string> async function fetchData(): Promise<string> { return "Data from API"; } const promise = fetchData(); console.log(stringPromiseSchema.parse(promise)); // 验证 Promise 对象本身 // 通常与 async parse 结合使用来验证解析后的值 stringPromiseSchema.parseAsync(promise).then(data => { console.log("Promise resolved data:", data); // data is string });
函数类型 (Experimental)
-
z.function(): 定义函数签名的 schema。.args(arg1Schema, arg2Schema, ...)或.args(z.tuple([arg1Schema, ...])): 定义参数的 schema。.returns(returnSchema): 定义返回值的 schema。.implement(func): 创建一个包装函数,该函数在执行前后会对其参数和返回值进行校验。
import { z } from 'zod'; const sumFunctionSchema = z.function() .args(z.number(), z.number()) // 定义两个参数,都是数字 .returns(z.number()); // 定义返回值是数字 type SumFunction = z.infer<typeof sumFunctionSchema>; // (arg0: number, arg1: number) => number // 使用 .implement 创建一个经过校验的函数 const safeSum = sumFunctionSchema.implement((a, b) => { return a + b; }); console.log(safeSum(5, 10)); // 15 try { // safeSum("5", 10); // 会在参数校验时抛出 ZodError // safeSum(5, "10"); // 会在参数校验时抛出 ZodError } catch (e) { console.error(e.errors); } // 示例:如果函数实现返回了错误类型 const faultySum = z.function() .args(z.number(), z.number()) .returns(z.number()) // 期望数字 .implement((a,b) => { // @ts-ignore 为了演示错误返回类型 return `${a+b}`; // 实际返回字符串 }); try { // faultySum(1,2); // 会在返回值校验时抛出 ZodError } catch (e) { console.error("Faulty sum error:", e.errors); }
4. Schema 修饰符与工具
这些方法可以链式调用到大多数 Zod schema 上,以添加额外的行为或约束。
-
.optional(): 使 schema 接受undefined作为有效值。字段可以不存在。import { z } from 'zod'; const optionalStringSchema = z.string().optional(); type OptionalString = z.infer<typeof optionalStringSchema>; // string | undefined console.log(optionalStringSchema.parse("hello")); console.log(optionalStringSchema.parse(undefined)); // optionalStringSchema.parse(null); // 抛出 ZodError -
.nullable(): 使 schema 接受null作为有效值。import { z } from 'zod'; const nullableStringSchema = z.string().nullable(); type NullableString = z.infer<typeof nullableStringSchema>; // string | null console.log(nullableStringSchema.parse("hello")); console.log(nullableStringSchema.parse(null)); // nullableStringSchema.parse(undefined); // 抛出 ZodError你可以组合使用
.optional().nullable()(或.nullable().optional()) 来表示一个值可以是T | null | undefined。更简洁的方式是使用.nullish(),它等同于.optional().nullable()。const nullishStringSchema = z.string().nullish(); // string | null | undefined type NullishString = z.infer<typeof nullishStringSchema>; -
.default(value): 如果输入数据为undefined,则提供一个默认值。注意:它只对undefined生效,对null或其他 falsy 值无效。 [18]import { z } from 'zod'; const nameSchema = z.string().default("Anonymous"); type Name = z.infer<typeof nameSchema>; // string console.log(nameSchema.parse("Alice")); // "Alice" console.log(nameSchema.parse(undefined)); // "Anonymous" (默认值生效) // console.log(nameSchema.parse(null)); // 抛出 ZodError,因为 null 不是 string 也不是 undefined如果希望
null也触发默认值,可以使用.preprocess():const nameOrDefaultSchema = z.preprocess( (val) => (val === null ? undefined : val), z.string().default("Anonymous") ); console.log(nameOrDefaultSchema.parse(null)); // "Anonymous" -
.describe(description): 为 schema 添加一个描述性字符串。这本身不影响验证,但可以用于文档生成或自定义错误信息。import { z } from 'zod'; const userIdSchema = z.string().uuid().describe("用户的唯一标识符 (UUID v4)"); console.log(userIdSchema.description); // "用户的唯一标识符 (UUID v4)" -
.transform(transformFn): 在数据成功通过验证后,对其进行转换。transformFn接收验证后的数据作为参数,并返回转换后的数据。 [19]import { z } from 'zod'; const stringToNumberSchema = z.string() .regex(/^\d+$/, "必须是数字字符串") .transform(Number); // 将验证后的字符串转换为数字 type StringToNumber = z.infer<typeof stringToNumberSchema>; // number console.log(stringToNumberSchema.parse("123")); // 123 (数字类型) // stringToNumberSchema.parse("abc"); // 验证失败 (regex) const userFullNameSchema = z.object({ firstName: z.string(), lastName: z.string(), }).transform(user => `${user.firstName} ${user.lastName}`); type UserFullName = z.infer<typeof userFullNameSchema>; // string console.log(userFullNameSchema.parse({ firstName: "Jane", lastName: "Doe" })); // "Jane Doe"重要:
z.infer推断的是transform之后的类型。 -
.refine(validatorFn, messageOrParams): 添加自定义验证逻辑。validatorFn接收要验证的数据,如果数据有效则返回true(或一个 truthy 值),无效则返回false(或一个 falsy 值)。 [11][18]import { z } from 'zod'; const passwordSchema = z.string() .min(8, "密码至少需要8个字符") .refine(s => /[A-Z]/.test(s), { message: "密码必须包含至少一个大写字母" }) .refine(s => /[a-z]/.test(s), { message: "密码必须包含至少一个小写字母" }) .refine(s => /\d/.test(s), { message: "密码必须包含至少一个数字" }); console.log(passwordSchema.safeParse("Password123")); // success: true console.log(passwordSchema.safeParse("password")); // success: false, 错误信息指向大写字母和数字的 refine // refine 可以用于对象级别,例如比较两个字段 const registrationSchema = z.object({ password: z.string().min(8), confirmPassword: z.string(), }).refine(data => data.password === data.confirmPassword, { message: "两次输入的密码不匹配", path: ["confirmPassword"], // 指定错误关联到 confirmPassword 字段 }); console.log(registrationSchema.safeParse({ password: "test1234", confirmPassword: "test1234" })); // success: true const result = registrationSchema.safeParse({ password: "test1234", confirmPassword: "test4321" }); if (!result.success) { console.log(result.error.flatten().fieldErrors); // { confirmPassword: [ '两次输入的密码不匹配' ] } } -
.superRefine(superValidatorFn): 一个更强大的refine版本,它接收一个额外的ctx(ZodRefinementCtx) 参数。ctx允许你添加多个错误,并且可以指定错误的路径和消息。import { z } from 'zod'; const complexObjectSchema = z.object({ startDate: z.date(), endDate: z.date(), value: z.number(), }).superRefine((data, ctx) => { if (data.endDate < data.startDate) { ctx.addIssue({ code: z.ZodIssueCode.custom, // 自定义错误码 message: "结束日期不能早于开始日期", path: ["endDate"], // 错误关联到 endDate 字段 }); } if (data.startDate > new Date() && data.value < 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "对于未来的日期,值不能为负", path: ["value"], }); } // 如果有需要,可以添加更多 issue }); const testData1 = { startDate: new Date("2024-01-15"), endDate: new Date("2024-01-10"), value: 100 }; const result1 = complexObjectSchema.safeParse(testData1); if (!result1.success) { console.log("Validation 1 errors:", result1.error.flatten()); /* Validation 1 errors: { fieldErrors: { endDate: [ '结束日期不能早于开始日期' ] }, formErrors: [] } */ } const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 5); // 5天后的日期 const testData2 = { startDate: futureDate, endDate: new Date(futureDate.getTime() + 86400000), value: -10 }; const result2 = complexObjectSchema.safeParse(testData2); if (!result2.success) { console.log("Validation 2 errors:", result2.error.flatten()); /* Validation 2 errors: { fieldErrors: { value: [ '对于未来的日期,值不能为负' ] }, formErrors: [] } */ } -
.preprocess(preprocessFn, schema): 在数据传递给内部 schema 进行验证之前,对其进行预处理。preprocessFn接收原始输入,其返回值将被传递给schema进行验证。这对于在验证前转换数据类型或格式非常有用。import { z } from 'zod'; // 示例:将输入的字符串(如果是数字字符串)或数字统一转换为数字 const flexibleNumberSchema = z.preprocess(arg => { if (typeof arg === "string") { const num = parseFloat(arg); return isNaN(num) ? arg : num; // 如果不能转为数字,则返回原字符串让后续的 z.number() 报错 } return arg; }, z.number({invalid_type_error: "必须是数字或数字字符串"})); console.log(flexibleNumberSchema.parse("123.45")); // 123.45 (数字) console.log(flexibleNumberSchema.parse(500)); // 500 (数字) // console.log(flexibleNumberSchema.safeParse("abc")); // success: false, error... // 示例:处理可能为 "true"/"false" 字符串的布尔值 const booleanLikeSchema = z.preprocess(val => { if (typeof val === 'string') { if (val.toLowerCase() === 'true') return true; if (val.toLowerCase() === 'false') return false; } return val; }, z.boolean()); console.log(booleanLikeSchema.parse("true")); // true console.log(booleanLikeSchema.parse("FALSE")); // false console.log(booleanLikeSchema.parse(true)); // true // console.log(booleanLikeSchema.safeParse("not a boolean")); // success: false -
.pipe(outputSchema): 链式验证。首先用当前 schema 验证数据,如果成功,则将结果(可能是经过转换的)传递给outputSchema进行进一步验证和转换。import { z } from 'zod'; // 步骤1: 确保输入是字符串,并转换为数字 const stringToNumber = z.string().transform(val => parseInt(val, 10)); // 步骤2: 确保转换后的数字是正数 const positiveNumber = z.number().positive(); // 使用 .pipe() 连接它们 const stringToPositiveNumberSchema = stringToNumber.pipe(positiveNumber); // 等价于: z.string().transform(val => parseInt(val, 10)).pipe(z.number().positive()) // 但更清晰的写法是分开定义 console.log(stringToPositiveNumberSchema.parse("123")); // 123 // stringToPositiveNumberSchema.parse("0"); // 抛出 ZodError (positiveNumber 校验失败) // stringToPositiveNumberSchema.parse("-10"); // 抛出 ZodError (positiveNumber 校验失败) // stringToPositiveNumberSchema.parse("abc"); // 抛出 ZodError (stringToNumber 校验失败,因为 parseInt("abc") 是 NaN) // 注意:如果 stringToNumber 内部的 transform 返回了 NaN, // 那么 NaN 会传递给 positiveNumber,positiveNumber 会校验失败。 // 如果希望在 parseInt 失败时就给出明确错误,可以这样做: const robustStringToPositiveNumberSchema = z.string() .regex(/^\d+$/, "必须是纯数字字符串") // 先确保是数字字符串 .transform(val => parseInt(val, 10)) .pipe(z.number().positive("数字必须为正数")); console.log(robustStringToPositiveNumberSchema.safeParse("100")); // success: true, data: 100 console.log(robustStringToPositiveNumberSchema.safeParse("-5")); // success: false, (regex 失败) console.log(robustStringToPositiveNumberSchema.safeParse("text"));// success: false, (regex 失败)
5. 错误处理与自定义错误
当 Zod 验证失败时,它会抛出(或在 safeParse 中返回)一个 ZodError 实例。这个实例包含了关于哪些验证失败以及为什么失败的详细信息。 [3]
ZodError 对象结构
ZodError 对象有一个 issues 数组,其中每个 ZodIssue 对象描述了一个具体的验证问题。一个 ZodIssue 通常包含:
code: 错误类型码 (例如invalid_type,too_small,custom)。path: 一个数组,表示数据中发生错误的路径 (例如['user', 'address', 'zipCode'])。message: 错误消息。- 其他特定于错误类型的属性 (例如
minimum对于too_small错误)。
import { z } from 'zod';
const schema = z.object({
name: z.string().min(5, "名称至少5个字符"),
age: z.number().positive(),
contact: z.object({
email: z.string().email(),
phone: z.string().optional()
})
});
const data = {
name: "Joe", // 太短
age: -30, // 不是正数
contact: {
email: "not-an-email" // 无效邮箱
}
};
const result = schema.safeParse(data);
if (!result.success) {
// result.error 是 ZodError 实例
console.log("原始 ZodError issues:");
result.error.issues.forEach(issue => {
console.log(` Path: ${issue.path.join('.')}, Message: ${issue.message}, Code: ${issue.code}`);
});
/*
原始 ZodError issues:
Path: name, Message: 名称至少5个字符, Code: too_small
Path: age, Message: Number must be positive, Code: too_small
Path: contact.email, Message: Invalid email, Code: invalid_string
*/
}
扁平化错误 (.flatten())
ZodError 实例上有一个 .flatten() 方法,它可以将错误组织成更易于在 UI 中显示的结构。它返回一个对象,包含:
formErrors: 一个字符串数组,包含与整个表单/对象相关的错误 (通常是refine或superRefine在顶层对象上产生的错误)。fieldErrors: 一个对象,键是字段路径,值是该字段的错误消息数组。
// ...接上例
if (!result.success) {
const flatErrors = result.error.flatten();
console.log("\n扁平化后的错误 (fieldErrors):");
for (const field in flatErrors.fieldErrors) {
console.log(` ${field}: ${flatErrors.fieldErrors[field].join(', ')}`);
}
if (flatErrors.formErrors.length > 0) {
console.log("\n扁平化后的错误 (formErrors):");
console.log(` ${flatErrors.formErrors.join(', ')}`);
}
/*
扁平化后的错误 (fieldErrors):
name: 名称至少5个字符
age: Number must be positive
contact.email: Invalid email (注意:flatten 默认不会递归到嵌套对象的子字段,除非你使用 .format())
要获取更深层次的扁平化错误,通常使用 .format(),它会递归地为每个字段创建 _errors 数组。
*/
const formattedErrors = result.error.format();
console.log("\n格式化后的错误 (format()):");
// console.log(JSON.stringify(formattedErrors, null, 2));
if (formattedErrors.name) {
console.log(` Name errors: ${formattedErrors.name._errors.join(', ')}`);
}
if (formattedErrors.age) {
console.log(` Age errors: ${formattedErrors.age._errors.join(', ')}`);
}
if (formattedErrors.contact && formattedErrors.contact.email) {
console.log(` Contact Email errors: ${formattedErrors.contact.email._errors.join(', ')}`);
}
/*
格式化后的错误 (format()):
Name errors: 名称至少5个字符
Age errors: Number must be positive
Contact Email errors: Invalid email
*/
}
.format() 方法提供了更结构化的错误输出,每个字段下都有一个 _errors 数组。
自定义错误信息
几乎所有的 Zod 校验方法都接受一个可选的 message 参数或一个包含 message 的对象参数,用于自定义该特定校验失败时的错误信息。 [16][18]
import { z } from 'zod';
const usernameSchema = z.string()
.min(3, { message: "用户名太短了,至少需要3个字符!" })
.max(15, { message: "用户名太长了,最多15个字符!" });
const ageSchema = z.number({
required_error: "年龄是必填项", // 如果字段缺失 (在对象中且非 optional)
invalid_type_error: "年龄必须是一个数字", // 如果类型不匹配
}).positive({ message: "年龄必须大于0" });
const userSchema = z.object({
username: usernameSchema,
age: ageSchema
});
console.log(userSchema.safeParse({ username: "Al", age: "twenty" }));
/*
{
success: false,
error: ZodError ... issues: [
{ code: 'too_small', path: [ 'username' ], message: '用户名太短了,至少需要3个字符!', ... },
{ code: 'invalid_type', path: [ 'age' ], message: '年龄必须是一个数字', ... }
]
}
*/
setErrorMap (全局/局部)
Zod 允许你提供一个自定义的错误映射函数 (ZodErrorMap),它可以完全控制如何从 ZodIssue 生成错误消息。你可以设置全局错误映射,或在 parse/safeParse 时传入局部的错误映射。
import { z, ZodErrorMap, ZodIssueCode } from 'zod';
// 自定义中文错误映射
const customErrorMap: ZodErrorMap = (issue, ctx) => {
let message: string;
switch (issue.code) {
case ZodIssueCode.invalid_type:
if (issue.expected === "string") {
message = `我们期望一个文本,但收到了 ${issue.received}`;
} else if (issue.expected === "number") {
message = `我们期望一个数字,但收到了 ${issue.received}`;
} else {
message = `类型无效,期望 ${issue.expected},但收到了 ${issue.received}`;
}
break;
case ZodIssueCode.too_small:
if (issue.type === "string") {
message = `内容太短了,至少需要 ${issue.minimum} 个字符`;
} else if (issue.type === "array") {
message = `列表项太少了,至少需要 ${issue.minimum} 项`;
} else if (issue.type === "number") {
message = `数字太小了,必须大于${issue.inclusive ? '=' : ''} ${issue.minimum}`;
} else {
message = "内容太小了";
}
break;
case ZodIssueCode.too_big:
// ... 类似处理
message = "内容太大了";
break;
case ZodIssueCode.custom:
message = issue.message || "自定义校验失败"; // 使用 issue 中提供的 message
break;
default:
message = ctx.defaultError; // 对于未处理的 code,使用 Zod 的默认消息
}
return { message };
};
// 设置全局错误映射 (在你的应用入口处执行一次)
// z.setErrorMap(customErrorMap);
// 或者在 parse/safeParse 时局部使用
const mySchema = z.string().min(5);
const data = 123;
// 使用局部错误映射
const result = mySchema.safeParse(data, { errorMap: customErrorMap });
if (!result.success) {
console.log("自定义错误信息:");
result.error.issues.forEach(issue => console.log(` ${issue.message}`));
/*
自定义错误信息:
我们期望一个文本,但收到了 number
*/
}
// 如果不设置全局,再次使用默认错误映射
const mySchema2 = z.number().gte(10);
const result2 = mySchema2.safeParse(5, { errorMap: customErrorMap });
if (!result2.success) {
console.log("自定义数字错误信息:");
result2.error.issues.forEach(issue => console.log(` ${issue.message}`));
/*
自定义数字错误信息:
数字太小了,必须大于= 10
*/
}
```这对于国际化 (i18n) 错误消息或统一应用内的错误提示风格非常有用。
## 6. 类型推断 (Type Inference)
Zod 最强大的特性之一是能够从 schema 自动推断出 TypeScript 类型。这是通过 `z.infer<typeof yourSchema>` 实现的。 [[3]](https://www.showapi.com/news/article/67eb4f504ddd79013c000fc6)[[10]](https://cloud.tencent.com/developer/article/2514901)
```typescript
import { z } from 'zod';
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string(),
price: z.number().positive(),
tags: z.array(z.string()).optional(),
inventory: z.object({
quantity: z.number().int().nonnegative(),
inStock: z.boolean(),
}).default({ quantity: 0, inStock: false }),
attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
});
// 使用 z.infer 推断类型
type Product = z.infer<typeof ProductSchema>;
/*
推断出的 Product 类型如下:
type Product = {
id: string;
name: string;
price: number;
tags?: string[] | undefined;
inventory: { // 注意这里因为有 default,所以 inventory 本身不是可选的
quantity: number;
inStock: boolean;
};
attributes?: Record<string, string | number | boolean> | undefined;
}
*/
// 现在可以在代码中使用 Product 类型,享受类型安全
function displayProduct(product: Product) {
console.log(`产品名称: ${product.name}`);
console.log(`价格: ¥${product.price.toFixed(2)}`);
if (product.tags && product.tags.length > 0) {
console.log(`标签: ${product.tags.join(', ')}`);
}
console.log(`库存: ${product.inventory.quantity}件, ${product.inventory.inStock ? '有货' : '无货'}`);
}
const validProductData = {
id: "123e4567-e89b-12d3-a456-426614174000",
name: "超级笔记本",
price: 7999.99,
tags: ["高性能", "轻薄"],
// inventory 会使用默认值
};
const parsedProduct = ProductSchema.parse(validProductData); // parsedProduct 的类型是 Product
displayProduct(parsedProduct);
const anotherProduct: Product = {
id: "another-uuid",
name: "智能手表",
price: 1299,
// tags 是可选的,可以不提供
inventory: { // inventory 不是可选的,必须提供,或者让 Zod 使用默认值
quantity: 50,
inStock: true,
},
attributes: {
color: "Black",
waterproof_level: 5
}
};
displayProduct(anotherProduct);
这种方式确保了你的运行时验证逻辑 (Zod schema) 和静态类型 (TypeScript type) 始终保持同步,避免了手动维护两套定义可能引入的不一致和错误。
7. 前端实战示例:复杂表单验证
让我们构建一个稍微复杂一点的前端表单验证示例。假设我们有一个用户注册表单。
HTML 结构 (index.html)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zod 表单验证示例</title>
<style>
body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; }
.container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input[type="text"], input[type="email"], input[type="password"], input[type="number"], select, textarea {
width: calc(100% - 22px); /* 减去 padding 和 border */
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
input[type="checkbox"] { margin-right: 5px; }
.error-message { color: red; font-size: 0.9em; margin-top: 3px; }
.success-message { color: green; font-weight: bold; margin-top: 10px; }
button {
background-color: #007bff; color: white; padding: 10px 15px;
border: none; border-radius: 4px; cursor: pointer; font-size: 1em;
}
button:hover { background-color: #0056b3; }
h2 { border-bottom: 1px solid #eee; padding-bottom: 10px; }
</style>
</head>
<body>
<div class="container">
<h2>用户注册</h2>
<form id="registrationForm">
<div class="form-group">
<label for="username">用户名:</label>
<input type="text" id="username" name="username">
<div class="error-message" id="error-username"></div>
</div>
<div class="form-group">
<label for="email">邮箱:</label>
<input type="email" id="email" name="email">
<div class="error-message" id="error-email"></div>
</div>
<div class="form-group">
<label for="password">密码:</label>
<input type="password" id="password" name="password">
<div class="error-message" id="error-password"></div>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码:</label>
<input type="password" id="confirmPassword" name="confirmPassword">
<div class="error-message" id="error-confirmPassword"></div>
</div>
<div class="form-group">
<label for="birthYear">出生年份:</label>
<input type="number" id="birthYear" name="birthYear" placeholder="例如: 1990">
<div class="error-message" id="error-birthYear"></div>
</div>
<div class="form-group">
<label for="profileType">账户类型:</label>
<select id="profileType" name="profileType">
<option value="">--请选择--</option>
<option value="PERSONAL">个人</option>
<option value="BUSINESS">商业</option>
</select>
<div class="error-message" id="error-profileType"></div>
</div>
<!-- 条件字段,仅当 profileType 为 BUSINESS 时显示 -->
<div class="form-group" id="businessNameGroup" style="display:none;">
<label for="businessName">公司名称:</label>
<input type="text" id="businessName" name="businessName">
<div class="error-message" id="error-businessName"></div>
</div>
<div class="form-group">
<label for="bio">个人简介 (可选):</label>
<textarea id="bio" name="bio" rows="3"></textarea>
<div class="error-message" id="error-bio"></div>
</div>
<div class="form-group">
<input type="checkbox" id="terms" name="terms">
<label for="terms" style="display: inline;">我同意服务条款</label>
<div class="error-message" id="error-terms"></div>
</div>
<button type="submit">注册</button>
<div class="success-message" id="successMessage"></div>
</form>
</div>
<!-- 引入 Zod (例如通过 CDN,实际项目中通常通过 npm 安装和打包工具引入) -->
<!-- <script src="https://cdn.jsdelivr.net/npm/zod@latest/zod.min.js"></script> -->
<!-- 为了本地运行,你需要下载 Zod 或使用打包工具。这里假设你已通过打包工具处理了 Zod 的引入 -->
<!-- 在 script type="module" 中可以直接 import -->
<script type="module" src="formValidation.js"></script>
</body>
</html>
TypeScript/JavaScript 逻辑 (formValidation.ts 或 .js)
import { z, ZodError, ZodIssueCode } from 'zod'; // 假设使用 ES模块
// --- 1. 定义 Zod Schema ---
const currentYear = new Date().getFullYear();
// 基础用户 Schema,不包含依赖于其他字段的校验
const baseRegistrationSchema = z.object({
username: z.string()
.min(3, "用户名至少需要3个字符")
.max(20, "用户名不能超过20个字符")
.regex(/^[a-zA-Z0-9_]+$/, "用户名只能包含字母、数字和下划线"),
email: z.string().email("无效的邮箱地址"),
password: z.string()
.min(8, "密码至少需要8个字符")
.regex(/[A-Z]/, "密码必须包含至少一个大写字母")
.regex(/[a-z]/, "密码必须包含至少一个小写字母")
.regex(/[0-9]/, "密码必须包含至少一个数字")
.regex(/[^A-Za-z0-9]/, "密码必须包含至少一个特殊符号"),
confirmPassword: z.string(),
birthYear: z.preprocess(
(val) => (typeof val === 'string' && val.trim() !== '' ? parseInt(val, 10) : undefined),
z.number({ invalid_type_error: "出生年份必须是数字" })
.int("出生年份必须是整数")
.min(currentYear - 100, `出生年份不能早于 ${currentYear - 100}`)
.max(currentYear - 18, `您必须年满18岁 (即出生年份不晚于 ${currentYear - 18})`)
.optional() // 使其在 preprocess 之前可以为空字符串,之后可以为 undefined
).or(z.literal(undefined)), // 允许完全不填,这样 preprocess 返回 undefined 时能通过
profileType: z.enum(["PERSONAL", "BUSINESS"], {
required_error: "请选择账户类型",
invalid_type_error: "无效的账户类型"
}),
businessName: z.string().optional(), // 初始为可选
bio: z.string()
.max(200, "个人简介不能超过200个字符")
.optional()
.transform(val => val === "" ? undefined : val), // 空字符串视作未填写
terms: z.literal(true, {
errorMap: () => ({ message: "您必须同意服务条款" }) // 自定义特定错误
}),
});
// 使用 superRefine 进行跨字段验证和条件验证
const refinedRegistrationSchema = baseRegistrationSchema
.superRefine((data, ctx) => {
// 1. 密码和确认密码匹配
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: ZodIssueCode.custom,
message: "两次输入的密码不匹配",
path: ["confirmPassword"],
});
// 也可以同时给 password 字段加错误,如果需要
// ctx.addIssue({
// code: ZodIssueCode.custom,
// message: "两次输入的密码不匹配",
// path: ["password"],
// });
}
// 2. 如果账户类型是 BUSINESS,则 businessName 是必填的
if (data.profileType === "BUSINESS") {
if (!data.businessName || data.businessName.trim().length < 2) {
ctx.addIssue({
code: ZodIssueCode.custom, // 或者 ZodIssueCode.too_small
message: "公司名称至少需要2个字符",
path: ["businessName"],
});
}
}
});
// 推断 TypeScript 类型
type RegistrationFormInput = z.input<typeof refinedRegistrationSchema>; // 获取输入类型 (parse 前)
type RegistrationFormOutput = z.output<typeof refinedRegistrationSchema>; // 获取输出类型 (parse 后, transform 后)
// 在这个例子中,input 和 output 类型差别不大,主要是 bio 可能从 "" 变为 undefined
// --- 2. DOM 元素获取 ---
const form = document.getElementById('registrationForm') as HTMLFormElement;
const successMessageDiv = document.getElementById('successMessage') as HTMLDivElement;
// 获取所有输入字段,以便统一处理错误显示
const inputFieldNames: (keyof RegistrationFormInput)[] = [
'username', 'email', 'password', 'confirmPassword', 'birthYear',
'profileType', 'businessName', 'bio', 'terms'
];
// --- 3. 事件监听与处理逻辑 ---
form.addEventListener('submit', function(event) {
event.preventDefault(); // 阻止表单默认提交行为
clearAllErrors(); // 清除之前的错误信息
successMessageDiv.textContent = ''; // 清除成功信息
// 从表单收集数据
const formData = new FormData(form);
const data: Record<string, any> = {}; // 使用 Record<string, any> 更灵活
formData.forEach((value, key) => {
// FormData 会将 checkbox 的值转为 "on" 或 undefined (如果未勾选)
// Zod 的 z.literal(true) 需要布尔值 true
if (key === 'terms') {
data[key] = (value === 'on');
} else if (form.elements.namedItem(key) instanceof HTMLInputElement && (form.elements.namedItem(key) as HTMLInputElement).type === 'number') {
// 对于数字输入,如果为空,FormData 可能得到空字符串
data[key] = value === '' ? undefined : value; // 让 preprocess 处理
}
else {
data[key] = value;
}
});
// 对于未勾选的 checkbox,FormData 可能不会包含其键,手动设置为 false
if (!data.hasOwnProperty('terms')) {
data.terms = false;
}
console.log("表单原始数据:", data);
// 使用 Zod 进行验证
const validationResult = refinedRegistrationSchema.safeParse(data);
if (validationResult.success) {
// 验证通过
successMessageDiv.textContent = '注册成功!数据已通过验证。';
console.log('验证通过的数据:', validationResult.data as RegistrationFormOutput);
// 在这里,你可以将 validationResult.data 发送到服务器
// form.reset(); // 可以选择清空表单
} else {
// 验证失败,显示错误信息
console.error('验证失败:', validationResult.error.flatten());
displayErrors(validationResult.error);
}
});
// 动态显示/隐藏公司名称字段
const profileTypeSelect = document.getElementById('profileType') as HTMLSelectElement;
const businessNameGroup = document.getElementById('businessNameGroup') as HTMLDivElement;
if (profileTypeSelect && businessNameGroup) {
profileTypeSelect.addEventListener('change', function() {
if (this.value === 'BUSINESS') {
businessNameGroup.style.display = 'block';
} else {
businessNameGroup.style.display = 'none';
// 清空 businessName 的值和错误(如果需要)
(document.getElementById('businessName') as HTMLInputElement).value = '';
const businessNameErrorDiv = document.getElementById('error-businessName');
if (businessNameErrorDiv) businessNameErrorDiv.textContent = '';
}
});
}
// --- 4. 辅助函数 ---
function displayErrors(error: ZodError<RegistrationFormInput>) {
const flatErrors = error.flatten(); // { formErrors: string[], fieldErrors: { [field: string]: string[] } }
// 显示字段特定错误
for (const field in flatErrors.fieldErrors) {
const fieldName = field as keyof RegistrationFormInput; // 类型断言
const errorDiv = document.getElementById(`error-${fieldName}`);
if (errorDiv) {
errorDiv.textContent = (flatErrors.fieldErrors[fieldName] ?? []).join(', ');
}
}
// 显示表单级别错误 (如果有) - 这个例子中主要通过 path 指定到字段
if (flatErrors.formErrors.length > 0) {
// 可以选择一个通用的地方显示这些错误,或者附加到某个特定字段
const generalErrorDiv = document.getElementById('error-terms') || successMessageDiv; // 借用一个地方
if (generalErrorDiv) {
generalErrorDiv.innerHTML += `<br/>其他错误: ${flatErrors.formErrors.join(', ')}`;
}
}
}
function clearAllErrors() {
inputFieldNames.forEach(fieldName => {
const errorDiv = document.getElementById(`error-${fieldName}`);
if (errorDiv) {
errorDiv.textContent = '';
}
});
}
// 确保在页面加载时,如果初始 profileType 是 BUSINESS,则显示 businessNameGroup
document.addEventListener('DOMContentLoaded', () => {
if (profileTypeSelect && profileTypeSelect.value === 'BUSINESS') {
if (businessNameGroup) businessNameGroup.style.display = 'block';
}
});
代码讲解 (formValidation.ts) :
-
Schema 定义 (
refinedRegistrationSchema) :-
我们首先定义了一个
baseRegistrationSchema,包含了对各个字段的基本类型、格式和长度的校验。username: 字符串,长度3-20,特定正则。email: 必须是 email 格式。password: 字符串,最小长度8,并包含大小写字母、数字和特殊符号。confirmPassword: 初始只是字符串,匹配校验在superRefine中。birthYear: 使用z.preprocess将输入的字符串(如果是数字)转为数字,然后进行年龄校验。允许为空或不填。profileType: 枚举类型,必须是 "PERSONAL" 或 "BUSINESS"。businessName: 初始为可选字符串。bio: 可选字符串,最大长度200。空字符串通过transform转为undefined。terms: 必须是true(通过z.literal(true)),并自定义了错误消息。
-
然后,我们使用
.superRefine()来处理跨字段的复杂校验逻辑:- 密码确认:检查
password和confirmPassword是否一致。如果不一致,通过ctx.addIssue为confirmPassword字段添加一个自定义错误。 - 条件必填:如果
profileType是 "BUSINESS",则businessName变为必填项,并检查其长度。
- 密码确认:检查
-
-
类型推断:
RegistrationFormInput = z.input<typeof refinedRegistrationSchema>: 推断出 Zod schema 解析之前的期望输入类型。RegistrationFormOutput = z.output<typeof refinedRegistrationSchema>: 推断出 Zod schema 解析并转换之后的输出类型。对于包含.transform()或.default()的 schema,输入和输出类型可能不同。
-
DOM 操作与事件监听:
-
获取表单元素和用于显示消息的 div。
-
给表单的
submit事件添加监听器。 -
在提交时,阻止默认行为,清除旧错误。
-
数据收集: 使用
FormData从表单收集数据。特别处理了checkbox(需要转为布尔值) 和可能为空的number输入。 -
验证: 调用
refinedRegistrationSchema.safeParse(data)进行验证。 -
结果处理:
- 如果
validationResult.success为true,显示成功消息,并可以在此将validationResult.data(类型为RegistrationFormOutput) 发送到服务器。 - 如果为
false,调用displayErrors函数来显示错误。
- 如果
-
动态字段显示: 根据
profileType的选择,动态显示或隐藏businessName字段。
-
-
错误显示与清除:
displayErrors(error):接收ZodError对象,使用error.flatten()来获取一个易于处理的错误结构。遍历fieldErrors,将错误消息显示在对应字段下方的div中。clearAllErrors(): 清除所有字段的错误消息。
运行这个例子:
-
将 HTML 代码保存为
index.html。 -
将 TypeScript/JavaScript 代码保存为
formValidation.js(或formValidation.ts并编译为.js) 在与index.html相同的目录下。 -
确保你的
formValidation.js中import { z, ZodError, ZodIssueCode } from 'zod';能够正确工作。- 如果你在现代浏览器中直接使用 ES 模块,并且 Zod 是通过 npm 安装的,你可能需要一个简单的开发服务器 (如
vite,live-server或 Node.js 的http-server) 来正确解析模块路径,或者使用指向 Zod ES 模块构建的 CDN 链接 (如果可用且支持裸模块导入)。 - 最常见的方式是使用打包工具 (如 Vite, Webpack, Parcel, Rollup) 来处理模块导入和打包。例如,使用 Vite,你只需
npm install zod,然后在formValidation.ts中import,Vite 会处理剩下的事情。
- 如果你在现代浏览器中直接使用 ES 模块,并且 Zod 是通过 npm 安装的,你可能需要一个简单的开发服务器 (如
-
用浏览器打开
index.html。
这个例子展示了 Zod 在前端表单验证中的强大能力,包括基本类型校验、复杂正则、跨字段校验、条件校验、自定义错误消息以及与 TypeScript 的无缝集成。代码量虽然没有达到1000行(这对于单个功能的示例是不切实际的),但它覆盖了许多核心概念和实际应用场景,并且非常详细。
8. 高级主题
异步 Refinements
.refine 和 .superRefine 中的验证函数可以是异步的 (返回一个 Promise)。如果使用了异步 refinement,你需要使用 schema.parseAsync() 或 schema.safeParseAsync()。 [11]
import { z } from 'zod';
// 模拟一个异步的用户名唯一性检查
async function isUsernameUnique(username: string): Promise<boolean> {
return new Promise(resolve => {
setTimeout(() => {
resolve(username !== "existingUser");
}, 500);
});
}
const asyncUsernameSchema = z.string()
.min(3, "用户名至少3字符")
.refine(async (username) => {
return await isUsernameUnique(username);
}, "该用户名已被占用");
async function testAsyncValidation() {
console.log("开始异步验证 (unique):", await asyncUsernameSchema.safeParseAsync("newUser"));
// { success: true, data: 'newUser' } (大约500ms后)
console.log("开始异步验证 (exists):", await asyncUsernameSchema.safeParseAsync("existingUser"));
// { success: false, error: ... } (大约500ms后,错误信息为 "该用户名已被占用")
}
testAsyncValidation();
递归 Schemas (例如:树状结构)
Zod 支持定义递归的 schema,这对于像文件系统树、评论嵌套等结构非常有用。你需要使用 z.lazy() 来延迟 schema 的初始化,从而打破循环依赖。
import { z } from 'zod';
// 定义 Category 类型,它可能包含子 Category
interface Category {
name: string;
subcategories?: Category[];
}
// 使用 z.lazy() 定义递归的 CategorySchema
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema).optional(), // 递归引用 CategorySchema
})
);
type InferredCategory = z.infer<typeof CategorySchema>;
const categoryData = {
name: "Electronics",
subcategories: [
{
name: "Computers",
subcategories: [
{ name: "Laptops" },
{ name: "Desktops" },
],
},
{
name: "Mobile Phones",
},
],
};
const invalidCategoryData = {
name: "Books",
subcategories: [
{ name: 123 } // name 应该是 string
]
}
console.log(CategorySchema.safeParse(categoryData)); // success: true
console.log(CategorySchema.safeParse(invalidCategoryData)); // success: false
Coercion (强制类型转换)
Zod v3.20 引入了便捷的 "coercion" (强制类型转换) 方法,如 z.coerce.string()、z.coerce.number()、z.coerce.boolean()、z.coerce.date()。这些方法会在验证之前尝试将输入值转换为目标类型。
z.coerce.string(): 将输入转换为字符串 (使用String(input))。z.coerce.number(): 将输入转换为数字 (使用Number(input))。z.coerce.boolean(): 将输入转换为布尔值 (使用Boolean(input))。z.coerce.date(): 将输入(字符串或数字)转换为日期对象 (使用new Date(input))。
import { z } from 'zod';
const coercedNumberSchema = z.coerce.number(); // 会尝试 Number(input)
console.log(coercedNumberSchema.parse("123")); // 123 (数字)
console.log(coercedNumberSchema.parse(true)); // 1 (数字)
// console.log(coercedNumberSchema.parse("abc")); // NaN, 如果后续有 .int() 等校验会失败
const coercedBooleanSchema = z.coerce.boolean();
console.log(coercedBooleanSchema.parse("true")); // true
console.log(coercedBooleanSchema.parse("")); // false
console.log(coercedBooleanSchema.parse(1)); // true
console.log(coercedBooleanSchema.parse(0)); // false
const coercedDateSchema = z.coerce.date();
console.log(coercedDateSchema.parse("2023-10-26T10:00:00.000Z")); // Date 对象
// console.log(coercedDateSchema.parse("invalid-date")); // 会得到 Invalid Date 对象,后续校验会失败
// 结合其他校验
const coercedPositiveNumber = z.coerce.number().positive("必须是正数");
console.log(coercedPositiveNumber.parse("42")); // 42
console.log(coercedPositiveNumber.safeParse("-5")); // { success: false, ... }
console.log(coercedPositiveNumber.safeParse("text")); // { success: false, ... } (因为 Number("text") 是 NaN)
这比手动使用 .preprocess() 进行常见类型转换要方便得多。
9. 总结与最佳实践
- 单一职责原则:保持你的 Zod schema 专注和目标单一。复杂的 schema 可以通过组合简单的 schema 来构建。 [20]
- 利用类型推断:始终使用
z.infer<typeof schema>来获取 TypeScript 类型,避免手动类型定义与 schema 定义的冗余和不一致。 [15][20] - 清晰的错误消息:为你的校验提供有意义的错误消息,特别是在
.refine、.superRefine或链式校验中,这有助于调试和用户反馈。 [16][20] safeParse优先:在前端,尤其是在处理用户输入或外部 API 数据时,优先使用safeParse来优雅地处理验证错误,而不是依赖try/catch。 [14][15]- 组织 Schema: 对于大型项目,考虑将 schema 组织在专门的目录或文件中,方便管理和复用。 [20]
- 组合优于继承: Zod 的设计鼓励使用组合(如
.extend(),.merge(),z.union(),z.intersection())来构建复杂的 schema。 - 测试你的 Schema: 像测试其他代码一样测试你的 Zod schema,确保它们能正确验证有效数据并拒绝无效数据,特别是对于包含复杂
refine或transform逻辑的 schema。 [15] - 理解
inputvsoutput类型: 当 schema 包含.transform(),.default()或.preprocess()时,z.input<T>和z.output<T>(或z.infer<T>,它通常等同于z.output<T>) 可能会不同。确保在合适的场景使用正确的推断类型。 - 谨慎使用
z.any()和z.unknown():z.any()会完全绕过类型检查和验证。z.unknown()更安全,因为它要求你在使用数据前进行类型收窄,但它仍然不提供结构验证。尽可能定义更具体的 schema。 [15]
Zod 是一个非常强大且灵活的库,它极大地改善了在 TypeScript (和 JavaScript) 项目中处理数据验证和类型安全的方式。通过熟练掌握其核心概念和 API,你可以构建更健壮、更可靠的前端应用程序。
好文推荐:
- Zod 中文网: 简介
- Zod: Intro
- 深入剖析TypeScript运行时类型安全:Zod库的实战应用 - 万维易源
- 使用Zod 进行TypeScript 类型验证 - 稀土掘金
- Learn Zod validation with React Hook Form - Contentful
- Form Validation with Type Inference Made Easy with Zod, the Best Sidekick for TypeScript
- Zod:TypeScript开发者必备的验证神器! | githubshare
- 使用Zod 於Runtime 檢驗型別 - 網頁東東
- Zod and React: A Perfect Match for Robust Validation - DhiWise
- zod-腾讯云开发者社区
- Schema Validation with Zod in 2025 - Turing
- 前端開發:將Zod 與TypeScript 結合使用 - CodeLove 論壇
- 表单验证太复杂?用Zod 让它变得简单又安全 - 稀土掘金
- 【Express.js】使用zod检验原创 - CSDN博客
- 使用Zod 库的最佳实践 - Digital Dispatch | 前沿数字视界
- A Complete Guide to Zod | Better Stack Community
- 大前端扫地僧之必备技能Zod
- Client & server-side validation with Zod. - DEV Community
- 【超详细】Zod 入门教程Zod 是一个以TypeScript 为首的模式声明和验证库
- Best Practices for Zod Schema Organization - Till it's done