一文搞定前端Zod.js

3,361 阅读26分钟

1. Zod.js 简介

什么是 Zod?

Zod 是一个以 TypeScript 为首的 schema 声明和验证库。 [1][2] 这意味着你首先定义一个数据的“形状”或“蓝图”(即 schema),然后 Zod 可以根据这个 schema 来验证实际数据是否符合预期。它不仅仅是做类型检查,更重要的是它在运行时进行数据验证。 [3][4]

为什么在前端使用 Zod?

在前端开发中,我们经常处理来自各种来源的数据,例如:

  • 用户表单输入 [5][6]
  • API 响应数据 [3][7]
  • URL 查询参数 [8]
  • LocalStorage 或其他客户端存储

这些数据在到达你的 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

[1][10]

环境要求

  • 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" }
    

    [12]

  • 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_INTEGERNumber.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: ... }
    

    [12]

  • z.bigint() : 验证数据是否为 BigInt。

  • z.boolean() : 验证数据是否为布尔值。 [12]

  • z.date() : 验证数据是否为 JavaScript Date 对象。

    • .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) : 验证数据是否同时符合 schemaAschemaB。通常用于合并对象 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) : 验证数据是否为 JavaScript Map 对象,并且其键和值分别符合 keySchemavalueSchema

    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) : 验证数据是否为 JavaScript Set 对象,并且其所有值都符合 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);
    }
    

    [10]

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
  */
}

[3]

扁平化错误 (.flatten())

ZodError 实例上有一个 .flatten() 方法,它可以将错误组织成更易于在 UI 中显示的结构。它返回一个对象,包含:

  • formErrors: 一个字符串数组,包含与整个表单/对象相关的错误 (通常是 refinesuperRefine 在顶层对象上产生的错误)。
  • 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) :

  1. 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() 来处理跨字段的复杂校验逻辑:

      • 密码确认:检查 passwordconfirmPassword 是否一致。如果不一致,通过 ctx.addIssueconfirmPassword 字段添加一个自定义错误。
      • 条件必填:如果 profileType 是 "BUSINESS",则 businessName 变为必填项,并检查其长度。
  2. 类型推断:

    • RegistrationFormInput = z.input<typeof refinedRegistrationSchema>: 推断出 Zod schema 解析之前的期望输入类型。
    • RegistrationFormOutput = z.output<typeof refinedRegistrationSchema>: 推断出 Zod schema 解析并转换之后的输出类型。对于包含 .transform().default() 的 schema,输入和输出类型可能不同。
  3. DOM 操作与事件监听:

    • 获取表单元素和用于显示消息的 div。

    • 给表单的 submit 事件添加监听器。

    • 在提交时,阻止默认行为,清除旧错误。

    • 数据收集: 使用 FormData 从表单收集数据。特别处理了 checkbox (需要转为布尔值) 和可能为空的 number 输入。

    • 验证: 调用 refinedRegistrationSchema.safeParse(data) 进行验证。

    • 结果处理:

      • 如果 validationResult.successtrue,显示成功消息,并可以在此将 validationResult.data (类型为 RegistrationFormOutput) 发送到服务器。
      • 如果为 false,调用 displayErrors 函数来显示错误。
    • 动态字段显示: 根据 profileType 的选择,动态显示或隐藏 businessName 字段。

  4. 错误显示与清除:

    • displayErrors(error):接收 ZodError 对象,使用 error.flatten() 来获取一个易于处理的错误结构。遍历 fieldErrors,将错误消息显示在对应字段下方的 div 中。
    • clearAllErrors(): 清除所有字段的错误消息。

运行这个例子:

  1. 将 HTML 代码保存为 index.html

  2. 将 TypeScript/JavaScript 代码保存为 formValidation.js (或 formValidation.ts 并编译为 .js) 在与 index.html 相同的目录下。

  3. 确保你的 formValidation.jsimport { 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.tsimport,Vite 会处理剩下的事情。
  4. 用浏览器打开 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,确保它们能正确验证有效数据并拒绝无效数据,特别是对于包含复杂 refinetransform 逻辑的 schema。 [15]
  • 理解 input vs output 类型: 当 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,你可以构建更健壮、更可靠的前端应用程序。


好文推荐:

  1. Zod 中文网: 简介
  2. Zod: Intro
  3. 深入剖析TypeScript运行时类型安全:Zod库的实战应用 - 万维易源
  4. 使用Zod 进行TypeScript 类型验证 - 稀土掘金
  5. Learn Zod validation with React Hook Form - Contentful
  6. Form Validation with Type Inference Made Easy with Zod, the Best Sidekick for TypeScript
  7. Zod:TypeScript开发者必备的验证神器! | githubshare
  8. 使用Zod 於Runtime 檢驗型別 - 網頁東東
  9. Zod and React: A Perfect Match for Robust Validation - DhiWise
  10. zod-腾讯云开发者社区
  11. Schema Validation with Zod in 2025 - Turing
  12. 前端開發:將Zod 與TypeScript 結合使用 - CodeLove 論壇
  13. 表单验证太复杂?用Zod 让它变得简单又安全 - 稀土掘金
  14. 【Express.js】使用zod检验原创 - CSDN博客
  15. 使用Zod 库的最佳实践 - Digital Dispatch | 前沿数字视界
  16. A Complete Guide to Zod | Better Stack Community
  17. 大前端扫地僧之必备技能Zod
  18. Client & server-side validation with Zod. - DEV Community
  19. 【超详细】Zod 入门教程Zod 是一个以TypeScript 为首的模式声明和验证库
  20. Best Practices for Zod Schema Organization - Till it's done