Zod数据验证入门:构建可靠的Web应用

深入了解Zod数据验证库,学习如何定义强大的数据模式、在运行时验证数据,以及与TypeScript无缝集成,从而提高Web应用的健壮性与可靠性。

阅读时长: 7 分钟
共 3465字
作者: eimoon.com

引言:Zod数据验证库概览

作为现代Web开发者,我们经常需要处理来自外部的数据源,例如不受控的第三方API响应或用户提交的表单输入。这些数据并非总能符合我们预期的格式和结构,这可能导致程序出现意想不到的错误和安全漏洞。Zod库应运而生,它提供了一种强大且优雅的方式来定义预期的数据模式(schemas),并基于这些模式在运行时对传入数据进行验证,从而让我们能够自信地处理数据,并在数据不正确时迅速捕获并抛出错误。

Zod是一个用于TypeScript/JavaScript的声明式Schema验证库,它允许开发者以类型安全的方式定义数据的形状和约束。它不仅能进行基础的类型检查,还能表达更复杂的验证逻辑,例如字符串长度、数字范围、正则表达式匹配等。

为什么选择Zod?弥补TypeScript的不足

TypeScript在代码中定义数据结构方面表现出色,其静态类型检查能力可以在编译时捕获大量类型错误,从而帮助我们编写出更健壮的代码。然而,TypeScript的功能存在以下局限性:

  1. 表达力限制: TypeScript类型系统无法表达“以user_id_开头且长度为20的字符串”或“介于1到5之间的整数”这类运行时约束。它只能定义为stringnumber
  2. 运行时检查缺失: TypeScript类型在编译成JavaScript后会被完全擦除。这意味着在运行时,程序不再拥有任何类型信息,除非我们手动编写大量的验证代码。这种手动验证代码不仅繁琐,而且容易出错,难以维护。

Zod完美填补了这些空白。使用Zod,我们可以编写出既与TypeScript类型相似,又拥有更多验证规则的数据模式:

  • 一个20字符且以user_id_开头的字符串可以表示为 z.string().startsWith('user_id_').length(20)
  • 一个介于1到5之间的整数可以表示为 z.number().int().gte(1).lte(5)

Zod的原始类型(primitive types)提供了丰富的链式(chained)函数,使我们能够更精确地定义期望的数据结构和约束。

与TypeScript不同,Zod模式在编译后仍然可用。当应用程序接收到数据时,我们可以使用Zod模式提供的parse函数对其进行验证。如果数据符合模式,parse将返回已验证的数据;否则,Zod将抛出一个ZodError,详细说明哪里出了问题。

Zod模式并非TypeScript的替代品,而是其极佳的补充。我们可以轻松地从Zod模式推断出TypeScript类型(使用z.infer<typeof YourSchema>),并在代码中正常使用这些类型。但在需要确保传入数据严格符合预设模式时,始终可以通过Zod模式进行运行时解析,从而获得额外的信心和保障。

核心概念:Zod模式(Schemas)的定义

Zod模式是定义数据结构期望、验证这些期望并根据需要转换数据的变量。Zod提供了与JavaScript原始类型(如stringnumberbooleandate等)相对应的多种基本类型函数。它还支持定义objectarray等原生数据结构,以及tupleenum等非原生数据结构的模式。

以下是一个验证披萨订单JavaScript对象的Zod模式示例,展示了Zod如何表达各种数据约束:

import { z } from 'zod';

const PizzaOrderSchema = z.object({
	diameter: z.number().gte(12).lte(28), // 一个介于12和28(含)之间的数字
	crust: z.enum(['thin', 'thick', 'stuffed']), // 必须是'thin', 'thick', 或 'stuffed'中的一个字符串
	toppings: z.array(z.string()), // 任意字符串组成的数组
	hasPineapple: z.boolean().optional(), // 可选的布尔值,可能为undefined
	orderCreated: z.date() // 一个JavaScript Date对象
});

在这个PizzaOrderSchema中:

  • diameter被严格限定在12到28的整数范围内。
  • crust使用了enum,确保其值只能是预设的几种类型之一。
  • toppings是一个string数组,可以包含任意字符串。
  • hasPineapple是一个可选的boolean类型,这意味着它可能存在也可能不存在。
  • orderCreated则要求传入一个合法的Date对象。

这种声明式的方式极大地提高了数据模型的可读性和可维护性。

数据解析与验证:实际操作

Zod模式不仅告诉程序数据应该是什么样子,还提供了强大的工具来轻松验证传入数据是否符合定义。这在验证用户输入或第三方API响应等场景中尤为重要。

以一个用户注册表单为例,我们需要验证邮箱地址的有效性,以及密码至少8个字符长并包含字母、数字和特殊字符,且两次输入的密码一致。

首先定义一个基本模式:

import { z } from 'zod';

const UserRegistrationSchema = z.object({
	email: z.string().email(), // 验证是否为有效邮箱格式
	password: z.string().min(8), // 密码至少8个字符
	confirmPassword: z.string().min(8) // 确认密码至少8个字符
});

为了检查密码是否匹配以及更复杂的密码规则(如包含字母、数字、特殊字符),我们可以使用Zod的refine函数。refine允许我们添加任意的自定义验证逻辑,它接收一个验证函数和一个可选的错误信息配置。

可以链式调用多个refine函数:

const UserRegistrationSchema = z
	.object({
		email: z.string().email(),
		password: z.string().min(8),
		confirmPassword: z.string().min(8),
	})
	// 验证密码是否匹配
	.refine(data => data.password === data.confirmPassword, {
		message: 'Passwords do not match!', // 自定义错误信息
		path: ['confirmPassword'], // 指明错误发生的路径
	})
	// 验证密码是否包含字母
	.refine(data => /[a-z]/i.test(data.password), {
		message: 'Password missing letters!',
		path: ['password'],
	})
	// 验证密码是否包含数字
	.refine(data => /\d/i.test(data.password), {
		message: 'Password missing numbers!',
		path: ['password'],
	})
	// 验证密码是否包含特殊字符
	.refine(data => /\W/i.test(data.password), {
		message: 'Password missing special characters!',
		path: ['password'],
	});

Zod提供了两种主要的验证方法:

  • parse(): 如果验证失败,将直接抛出ZodError错误。这适合于你知道数据应该符合模式,否则就是程序性错误的情况。
  • safeParse(): 返回一个包含success属性(truefalse)的对象。如果验证失败,successfalse,并且会包含一个error属性,其中包含ZodError的详细信息。这更适合处理来自外部的、不确定的数据,可以优雅地处理错误。

例如,以下数据将导致验证失败:

// This will fail validation!
const userInput1 = {
	email: 'foo', // 无效邮箱
	password: 'bar', // 太短,无数字/特殊字符
	confirmPassword: 'baz' // 与密码不匹配
};

// This will succeed validation!
const userInput2 = {
	email: '[email protected]',
	password: 'Tr0ub4dor&3',
	confirmPassword: 'Tr0ub4dor&3'
};

try {
    UserRegistrationSchema.parse(userInput1); // 会抛出 ZodError
} catch (error) {
    if (error instanceof z.ZodError) {
        console.error('Validation failed:', error.errors);
        /*
        输出示例:
        [
          {
            "code": "invalid_string",
            "validation": "email",
            "message": "Invalid email",
            "path": [ "email" ]
          },
          {
            "code": "too_small",
            "minimum": 8,
            "type": "string",
            "inclusive": true,
            "exact": false,
            "message": "String must contain at least 8 character(s)",
            "path": [ "password" ]
          },
          {
            "code": "too_small",
            "minimum": 8,
            "type": "string",
            "inclusive": true,
            "exact": false,
            "message": "String must contain at least 8 character(s)",
            "path": [ "confirmPassword" ]
          },
          {
            "code": "custom",
            "message": "Passwords do not match!",
            "path": [ "confirmPassword" ]
          },
          // ...其他refine的错误
        ]
        */
    }
}

const result = UserRegistrationSchema.safeParse(userInput1);
if (!result.success) {
    console.error('Validation failed (safeParse):', result.error.errors);
}

通过为refine函数提供自定义的messagepath参数,我们可以提供更具描述性的错误信息,帮助用户快速定位问题并进行修正。

无缝集成TypeScript:类型推断

Zod与TypeScript的集成是其最强大的特性之一。使用z.infer<typeof YourSchema>可以从任何Zod模式推断出相应的TypeScript类型,从而避免重复编写类型定义,并确保类型定义与运行时验证逻辑始终保持同步:

import { z } from 'zod';

const ExampleSchema = z.object({
	foo: z.string(),
	bar: z.number()
});

// 从Zod模式推断出TypeScript类型
type Example = z.infer<typeof ExampleSchema>;

// Equivalent to:
// type Example = {
//	foo: string;
//	bar: number;
// }

const example1: Example = { foo: 'test', bar: 53 }; // Type is valid!
// const example2: Example = 'this will fail'; // 编译时报错:Type '"this will fail"' is not assignable to type 'Example'.
// const example3: Example = { foo: 123, bar: 'abc' }; // 编译时报错:Type 'number' is not assignable to type 'string'.

需要注意的是,从Zod模式推断的TypeScript类型并不会在类型层面提供Zod的运行时数据验证能力。例如,从z.string().min(3).max(20)创建的TypeScript类型仍然只是string。这意味着,即使我们使用了推断出的类型,编译器也无法阻止你将一个短字符串赋值给它:

type MyString = z.infer<typeof z.string().min(3).max(20)>; // MyString 实际上是 string

const myValue: MyString = "a"; // TypeScript 不会报错,因为 "a" 是一个 string
// 但在运行时,如果你用 z.string().min(3).max(20).parse("a") 验证,就会失败。

因此,即使使用了推断类型,仍然需要在运行时使用parse()safeParse()来验证传入数据,以确保数据的有效性。

一个常见的实践是将Zod模式命名为YourSchema(例如UserRegistrationSchema),以明确区分它与推断出的TypeScript类型(例如UserRegistration),从而避免混淆。

结论

Zod是一个出色的工具,能够帮助开发者轻松且自信地断言所处理数据正是您所期望的格式。它使我们能够在运行时验证数据,并制定清晰的策略来处理不正确的数据,从而显著提高应用程序的健壮性和用户体验。结合从Zod模式推断TypeScript类型的能力,它使我们能够编写出并运行更可靠、更值得信赖的代码。无论是在API开发、前端表单验证还是处理外部数据源时,Zod都是一个不可或缺的利器。

关于

关注我获取更多资讯

公众号
📢 公众号
个人号
💬 个人号
使用 Hugo 构建
主题 StackJimmy 设计