前言
TypeScript 能够在编码和编译阶段帮助我们发现类型错误,但它无法保证程序在运行时接收到的数据一定符合类型定义。
例如,接口声明返回的是一个用户对象:
interfaceUser{name:string;age:number;}但后端实际返回的数据可能是:
{"name":123,"age":"18"}这份数据显然不符合User类型,但 TypeScript 不会自动检查接口真实返回的 JSON。
这正是 Zod 要解决的问题。
一、Zod 是什么?
Zod 是一个以 TypeScript 为核心的运行时数据校验库。
开发者可以通过 Zod 定义一份数据结构规则,也就是 Schema,然后使用这份 Schema:
- 在运行时校验数据;
- 获得详细的校验错误;
- 自动推导出对应的 TypeScript 类型。
简单来说:
TypeScript 负责检查我们编写的代码,Zod 负责检查程序运行时接收到的数据。
Zod 的基本工作流程如下:
定义 Schema ↓ 接收外部未知数据 ↓ 运行时校验 ↓ 获得类型安全的数据二、为什么需要 Zod?
1. TypeScript 类型只存在于编译阶段
下面的代码看起来没有问题:
interfaceUser{name:string;age:number;}constuser:User={name:"小明",age:18,};TypeScript 可以检查出开发者是否把错误类型的数据赋值给user。
但是,TypeScript 类型会在编译后被删除。程序运行时并不存在User这个接口,因此 TypeScript 无法检查来自外部的数据。
常见的外部数据包括:
- 后端接口返回值;
- 用户提交的表单;
- URL 查询参数;
- JSON 配置文件;
这些数据本质上都不可信,需要在进入业务逻辑之前进行校验。
2. 类型断言不会校验数据
很多项目会使用类型断言处理接口返回值:
constresponse=awaitfetch("/api/user");constuser=(awaitresponse.json())asUser;这里的as User并不会验证数据,它只是告诉 TypeScript:
我确定这份数据是
User,请按照User类型处理。
如果接口返回:
{"name":100,"age":"十八岁"}TypeScript 仍然会把它当成User。
后续代码可能出现运行时错误:
user.name.toUpperCase();由于真实的name是数字,调用toUpperCase()时就会报错。
3. Zod 可以建立运行时安全边界
使用 Zod 后,可以在外部数据进入业务逻辑之前完成校验:
后端接口、用户输入、配置文件 ↓ Zod 校验层 ↓ 可信的业务数据 ↓ 业务逻辑这样做的核心价值是:
- 错误能够更早暴露;
- 错误位置更加明确;
- 业务代码可以放心使用校验后的数据;
- 减少类型定义和校验规则不一致的问题。
三、安装 Zod
使用 npm 安装:
npminstallzod四、Zod 的基本用法
1. 定义 Schema
首先通过 Zod 描述数据应该具有什么结构:
import{z}from"zod";constUserSchema=z.object({id:z.number(),name:z.string(),age:z.number(),});这份 Schema 表示:
id必须是数字;name必须是字符串;age必须是数字。
2. 校验数据
可以使用parse()校验数据:
constuser=UserSchema.parse({id:1001,name:"小明",age:18,});如果数据符合 Schema,parse()会返回校验后的数据。
如果数据不符合 Schema,则会抛出异常:
UserSchema.parse({id:"1001",name:"小明",age:"18",});这里的id和age都不是数字,因此校验会失败。
3. 自动推导 TypeScript 类型
Zod 可以通过z.infer从 Schema 中自动生成 TypeScript 类型:
typeUser=z.infer<typeofUserSchema>;推导结果相当于:
typeUser={id:number;name:string;age:number;};这意味着我们只需要维护一份 Schema:
constUserSchema=z.object({id:z.number(),name:z.string(),age:z.number(),});typeUser=z.infer<typeofUserSchema>;相比于分别维护类型和校验逻辑,这种方式更加可靠:
Zod Schema ├── 用于运行时校验 └── 用于推导 TypeScript 类型五、一个完整的接口校验示例
假设有一个获取用户信息的接口:
import{z}from"zod";constUserSchema=z.object({id:z.number(),name:z.string().min(1,"姓名不能为空"),age:z.number().int().min(0,"年龄不能小于 0"),email:z.string().email("邮箱格式不正确"),});typeUser=z.infer<typeofUserSchema>;asyncfunctiongetUser():Promise<User|null>{constresponse=awaitfetch("/api/user");// 外部数据默认视为 unknown,避免在校验前直接使用constunknownData:unknown=awaitresponse.json();// 校验接口返回值,并以结果对象的方式处理失败情况constresult=UserSchema.safeParse(unknownData);if(!result.success){console.error("用户数据格式错误:",result.error);returnnull;}// 校验成功后,data 已被推导为 User 类型returnresult.data;}如果接口返回:
{"id":1001,"name":"小明","age":18,"email":"xiaoming@example.com"}校验成功,返回值会被推导为User类型。
如果接口返回:
{"id":"1001","name":"","age":-1,"email":"not-an-email"}校验会失败,因为:
id应该是数字;name不能为空;age不能小于0;email不是合法邮箱。
六、parse和safeParse的区别
Zod 最常用的两个校验方法是:
parse();safeParse()。
1.parse()
parse()校验成功时返回数据,失败时抛出异常。
try{constuser=UserSchema.parse(input);console.log(user);}catch(error){console.error("数据校验失败:",error);}适合以下场景:
- 配置错误时程序不能继续运行;
- 数据不合法属于异常情况;
- 外层已经有统一异常处理。
2.safeParse()
safeParse()不会主动抛出异常,而是返回一个结果对象:
constresult=UserSchema.safeParse(input);if(result.success){console.log("校验成功:",result.data);}else{console.log("校验失败:",result.error);}校验成功时:
{success:true,data:...}校验失败时:
{success:false,error:...}适合以下场景:
- 表单校验;
- 接口参数校验;
- 需要向用户展示错误信息;
- 校验失败后仍然需要执行其他逻辑。
选择建议
| 场景 | 推荐方法 |
|---|---|
| 校验失败就终止执行 | parse() |
| 需要手动处理校验结果 | safeParse() |
| 表单提交 | safeParse() |
| 应用启动配置 | parse() |
| 接口返回值 | 通常使用safeParse() |
七、常见数据类型
字符串
constNameSchema=z.string();添加长度限制:
constNameSchema=z.string().min(1,"姓名不能为空").max(20,"姓名不能超过 20 个字符");数字
constAgeSchema=z.number();限制为非负整数:
constAgeSchema=z.number().int("年龄必须是整数").min(0,"年龄不能小于 0");布尔值
constEnabledSchema=z.boolean();数组
constTagsSchema=z.array(z.string());对应的数据:
consttags=["TypeScript","Zod","React"];也可以限制数组长度:
constTagsSchema=z.array(z.string()).min(1,"至少选择一个标签");对象
constProductSchema=z.object({id:z.number(),name:z.string(),price:z.number().positive(),});可选字段
使用optional()表示字段可以不存在:
constUserSchema=z.object({name:z.string(),nickname:z.string().optional(),});推导出的类型是:
typeUser={name:string;nickname?:string;};允许null
使用nullable()表示字段可以是null:
constUserSchema=z.object({name:z.string(),avatar:z.string().nullable(),});这里需要区分:
z.string().optional();// string | undefinedz.string().nullable();// string | null如果两种情况都允许:
constAvatarSchema=z.string().nullish();可以理解为:
string|null|undefined枚举值
constStatusSchema=z.enum(["pending","success","failed",]);typeStatus=z.infer<typeofStatusSchema>;推导出的类型为:
typeStatus="pending"|"success"|"failed";八、嵌套对象校验
实际业务数据通常是嵌套结构:
import{z}from"zod";constAddressSchema=z.object({province:z.string(),city:z.string(),detail:z.string(),});constUserSchema=z.object({id:z.number(),name:z.string(),address:AddressSchema,});对应的数据:
constinput={id:1001,name:"小明",address:{province:"北京",city:"北京市",detail:"朝阳区某街道",},};constuser=UserSchema.parse(input);复杂 Schema 可以拆分成多个小 Schema,以提高复用性和可读性。
九、自定义业务规则
基础类型正确并不代表数据一定符合业务要求。
例如,注册密码必须同时包含字母和数字:
constPasswordSchema=z.string().min(8,"密码至少需要 8 个字符").refine((password)=>/[a-zA-Z]/.test(password)&&/\d/.test(password),{message:"密码必须同时包含字母和数字",},);或者校验确认密码是否一致:
constRegisterSchema=z.object({password:z.string().min(8),confirmPassword:z.string(),}).refine((data)=>data.password===data.confirmPassword,{message:"两次输入的密码不一致",path:["confirmPassword"],},);这里的path表示错误应该归属到哪个字段,方便表单展示错误信息。
十、Zod 和 TypeScript 的区别
| 对比维度 | TypeScript | Zod |
|---|---|---|
| 检查时间 | 编译阶段 | 运行阶段 |
| 检查对象 | 源代码 | 真实数据 |
| 运行后是否存在 | 不存在 | 存在 |
| 能否校验接口返回值 | 不能自动校验 | 可以 |
| 能否返回校验错误 | 不可以 | 可以 |
| 能否推导类型 | 可以 | 可以通过 Schema 推导 |
两者并不是替代关系,而是互补关系:
TypeScript:保证代码内部的类型安全 Zod:保证外部数据进入系统时的类型安全十一、Zod 和手写校验的区别
不使用 Zod 时,我们可能会这样校验数据:
functionisUser(value:unknown):boolean{if(typeofvalue!=="object"||value===null){returnfalse;}constuser=valueasRecord<string,unknown>;return(typeofuser.id==="number"&&typeofuser.name==="string"&&typeofuser.age==="number");}这种方式存在一些问题:
- 校验代码比较繁琐;
- 嵌套数据很难维护;
- 错误信息不够详细;
- 类型定义和校验规则容易不一致;
- 新增字段时可能忘记修改校验函数。
使用 Zod 后:
constUserSchema=z.object({id:z.number(),name:z.string(),age:z.number(),});代码更加简洁,而且可以直接推导类型。
十二、常见误区
误区一:使用as就能保证数据类型
错误示例:
constuser=inputasUser;类型断言不会校验数据,只会绕过 TypeScript 的检查。
更安全的写法:
constuser=UserSchema.parse(input);误区二:所有内部数据都需要重复校验
Zod 最适合用在系统边界,而不是在每一个函数内部重复校验。
推荐在以下位置校验:
HTTP 请求入口 接口响应入口 表单提交入口 配置文件加载入口 本地存储读取入口 第三方数据接入入口数据经过校验后,系统内部可以直接使用对应的 TypeScript 类型。
误区三:Zod 会自动把字符串转换成数字
下面的数据不会通过z.number():
constinput="18";z.number().parse(input);因为"18"是字符串,不是数字。
如果需要处理表单或 URL 参数,可以明确使用类型转换规则:
constAgeSchema=z.coerce.number().int().min(0);constage=AgeSchema.parse("18");转换后的age是数字18。
需要注意:
类型转换应该是明确的业务行为,而不是默认假设所有错误类型都可以自动修复。
十三、适合使用 Zod 的场景
Zod 特别适合以下项目:
前端表单
校验用户名、手机号、邮箱、密码等用户输入。
API 接口
校验后端响应是否符合前端预期。
Node.js 服务端
校验请求参数、请求体和环境变量。
配置管理
应用启动时检查配置文件或环境变量是否合法。
全栈 TypeScript 项目
使用同一份 Schema 统一前后端的数据规则。
十四、什么时候不一定需要 Zod?
Zod 并不是所有场景都必须使用。
以下情况可以暂时不引入:
- 项目非常小,没有外部数据输入;
- 数据已经被其他框架可靠校验;
- 只需要简单的编译阶段类型检查;
- 引入额外依赖的成本大于校验收益。
是否使用 Zod,关键要看:
系统是否需要在运行时验证不可信数据。
如果项目大量依赖接口、表单、配置或者第三方数据,引入 Zod 通常很有价值。
十五、总结
Zod 的核心思想并不复杂:
不要直接相信外部数据,先校验,再使用。
它主要解决了 TypeScript 无法进行运行时数据校验的问题,并将 Schema、运行时校验和类型推导结合在一起。
使用 Zod 的基本步骤可以归纳为:
import{z}from"zod";// 1. 定义数据结构和校验规则constUserSchema=z.object({name:z.string().min(1),age:z.number().int().min(0),});// 2. 从 Schema 自动推导 TypeScript 类型typeUser=z.infer<typeofUserSchema>;// 3. 校验不可信的外部数据constresult=UserSchema.safeParse({name:"小明",age:18,});// 4. 只使用校验成功的数据if(result.success){constuser:User=result.data;console.log(user);}最后可以用一句话记住 Zod:
TypeScript 让代码具有类型,Zod 让真实数据符合类型。