Zod是一个TypeScript优先的模式声明和验证库,使用 Zod可以帮助我们在运行时对不可预测 数据进行校验

可选链式进行防御式编程

前置原因

老项目中后端是弱类型的的PHP服务,一些API接口在返回数据的时候可能会存在以下这些问题

1.数据结构与约定不一致

例如我们约定返回如下数据结构

{
	id: 1,
	user: {
		name: '',
		age: 10
	}
}

但是因为某些原因,例如依赖服务调动失败,默认值设置错误等,可能会返回下面的数据

{
	id: 1,
	user: []
}

或者压根就没有 user 字段

{
	id: 1
}

当出现这种情况时,前端如果没有进行全面有效的兼容处理,就会导致报错,如果是在 React Render 中报错,会导致页面渲染崩溃

2.数据类型不一致

还有一种情况就是,开发时前后端约定好了返回的字段应该是什么类型的,例如 idagenumber 类型,开发和测试时都没有问题,但是一上线,就出现了返回了字符串类型的 id 或者 age(别问为什么这样,问就是服务端确实可能这样返回)。这样的不确定性会导致前端如果一个兼容不好,就会出现线上问题,例如进行number运算时,接口返回了 string 类型,就先出现 1+1=11的问题。

所以老的项目中有非常多的隐式转换存在:

const isPlus = +user.plus_level > 0; 

还有就是因为上面数据结构返回不一致的原因,还会存在很多为了兼容而兼容的代码

const name = user?.name;

有大量的+隐式转换以及?.存在。
但是这样依然无法保证不出现问题,因为难免会有疏漏的情况,而且这种解决方案肯定是不对的。

最好的解决办法就是:服务端处理这个问题,不管用什么方式,升级也好、重构也好,需要保证返回的数据结构与类型应该与约定时一致,但因为各种问题,改造工作一直没有进行。

这种情况下为了保证生产环境的稳定性,需要前端来承接更多的兜底工作,但总不能要求每个人在接接口的时候考虑到每个字段是否有不返回的情况、返回的数据类型对不对,这样不但工作量大,而且也不严谨。

如果项目使用的是 TypeScript,那么Zod就是解决这个问题的最好选择

如何使用

Zod没有非常复杂的概念,如果你会使用TypeScript,那Zod就会更加简单,关于 Zod的基础用法可以直接看文档 Zod | Documentation.

这里只描述如何使用 Zod 来进行API接口返回数据的验证.

假如有如下代码:

interface UserDetail {
	name: string;
	age: number;
	phone: number;
	address: string;
}
 
export async function getUserDetail(): Promise<UserDetail> {
	try {
		const data = await fetch('/api/userDetail')
		return data;
	} catch(err) {
		throw err;
	}
}

getUserDetail 是一个接口函数,用来获取用户的详细信息。UserDetail 是定义的返回数据的结构和类型。

对接口返回字段进行验证

import { z } from 'zod';
 
const userSchema = {
	name: z.string(),
	age: z.number(),
	phone: z.number(),
	address: z.string()
}
 
export async function getUserDetail(): Promise<UserDetail> {
	try {
		const data = await fetch('/api/userDetail')
		return userSchema.parse(data);
	} catch(err) {
		throw err;
	}
}

如果接口返回的数据与定义的Schema对应不上,如缺少字段、字段类型不对,那么就会抛出错误信息,
我们可以根据错误信息通过统一的处理函数来处理,同时也不会进入我们正常的业务流程。这样就实现了在
运行时时对接口返回数据进行校验。而不需要手写很多的校验函数

data: { name: 123, age: 18, phone: '', address: '' }
 
{
  "code": "invalid_type",
  "expected": "string",
  "received": "number",
  "path": [0, "name"],
  "message": "Expected string, received number"
}

如果在校验异常时不希望中断代码并抛出异常,可以使用 safeParse 做更精细的控制

const product = productSchema.safeParse(res)
if (product.success) {
	console.log(product.data.detail.name)
	console.log(product.data.detail.price)
} else {
	console.error(product.error)
}

TypeScript 类型与Zod

使用 Zod 时需要先定义 Schema,可以看到,Schema与我们定义的 TypeScript 类型很相似,但是我们定义了两次,会比较麻烦。
我们可以使用 Zodinfer来自动生成TypeScript类型,通过一次定义同时生成 SchemaTypescript

// 先定义 Schema
const userSchema = {
	name: z.string(),
	age: z.number(),
	phone: z.number(),
	address: z.string()
}
 
// 通过 infer 生成Typescript 类型
 
type UserDetail = z.infer<typeof userSchema>;

UserDetail 与我们最开始手动定义的 interface 是一样的

interface UserDetail {
	name: string;
	age: number;
	phone: number;
	address: string;
}

默认值与可选值

通过optional()default() 来实现

import { z } from 'zod';
 
const userSchema = {
	name: z.string(),
	age: z.number(),
	address: z.string().optional(),
	keywords: z.array(z.string([]))
}

通过 optional() 可以显示的声明某个字段为可选值,当接口没有返回 address 时不会触发 catch
default() 可以定义某个值的默认值,当接口没有返回某个字段的时候,会返回我们设置的默认值,设置了 default() 之后就无需再设置 optional

对 Array 数据进行校验

通过 z.arrayz.object 配合实现对复杂数据结构的定义和校验, 同时可以对返回的数据进行过滤,只返回我们需要的字段

import { z } from 'zod';
 
// 模拟常见的 list api 接口返回数据
const list = [
  { name: 'Yang', age: 10, avatar: '' },
  { name: 'Yang', age: 18, avatar: '' },
  { name: 'Man', age: 28, avatar: '' },
];
 
const arrayObjectSchema = z.array(
  z.object({
    name: z.string(),
    age: z.number(),
  })
);
 
type ArrayObjectType = z.infer<typeof arrayObjectSchema>;
 
const parsed = arrayObjectSchema.parse(list);
/**
 * 校验通过
 * 过滤后的数据为
 * [
 *  {age: 10, name: "Yang"},
 *  {age: 18, name: 'Yang'}
 *  {age: 28, name: 'Man'}
 * ]
 */
console.log('parsed:', parsed);

校验不通过的情况

/**
 * Error:校验不通过,name 字段类型不正确
 * {
  "code": "invalid_type",
  "expected": "string",
  "received": "number",
  "path": [
  0,
  "name"
  ],
  "message": "Expected string, received number"
  }
 */
const parsed1 = arrayObjectSchema.parse([
  { name: 123, age: 18 },
  { name: 'Yang', age: 28 },
]);
 
/**
 * Error: 缺少必须字段
 * {
  "code": "invalid_type",
  "expected": "number",
  "received": "undefined",
  "path": [
  1,
  "age"
  ],
  "message": "Required"
  }
 */
const parsed2 = arrayObjectSchema.parse([
  { name: 'Yang', age: 18 },
  { name: 'Li' },
]);

联合函数与字面量

在typescript中如果先限制某个值只可以是某种类型,可以使用联合类型,例如

type Name = 'Li' | 'Yang'; // 表示 name 只能为 Li 或者 Yang

在 zod 中也有对应的方式可以实现这种类型校验

const UserSchema = z.object({
  name: z.union([z.literal('Li'), z.literal('Yang')]),
  age: z.number(),
});
 
/**
 * 转换出来的 TS 类型为:
 * type UserType = {
    name: "Li" | "Yang";
    age: number;
  }
 */
type UserType = z.infer<typeof UserSchema>;
 
/**
 * Success
 */
UserSchema.parse({
  age: 12,
  name: 'Li',
});
 
/**
 * Success
 */
UserSchema.parse({
  age: 12,
  name: 'Yang',
});
 
/**
 * Error
 * "Invalid literal value, expected \"Li\""
 * "Invalid literal value, expected \"Yang\""
 */
UserSchema.parse({
  age: 12,
  name: 'M',
});
 
/**
 * 还有一种计较简洁的方法 就是使用枚举类型
 */
 
const UserEnumSchema = z.object({
  age: z.number(),
  name: z.enum(['Man', 'Lou']),
});
 
/**
 * type UserEnumType = {
      name: "Man" | "Lou";
      age: number;
  }
 */
type UserEnumType = z.infer<typeof UserEnumSchema>;
 
/**
 * Success
 */
UserEnumSchema.parse({
  age: 12,
  name: 'Man',
});
 
/**
 * Error:
 * "Invalid enum value. Expected 'Man' | 'Lou', received 'Yang'"
 */
UserEnumSchema.parse({
  age: 12,
  name: 'Yang',
});
 

数据转换

通过 API 获取到的数据,我们知道该如何将其需要的字段提取出来,不去关心我们不需要的字段,
还可以将数据进行转换,例如将返回的某个字段类型 转换为 另外一种类型

假如这是API接口返回的用户信息

const data = {
  id: 123344555,
  name: 'Li Yang',
  age: 123,
};

要求将接口返回的用户信息中的 name 字段按照空格分割成一个数组,并创建一个新的字段添加到返回数据中且保留 name 字段

<!-- 通过 transfrom 对整个 Schema 对象进行转换 -->
const UserSchema = z
  .object({
    id: z.number(),
    name: z.string(),
    age: z.number(),
  })
  .transform((res) => ({
    ...res,
    nameArr: res.name.split(' '),
  }));
 
/**
 * {
    age: 123
    id: 123344555
    name: "Li Yang"
    nameArr: ['Li', 'Yang]
 }
 */
const parsed = UserSchema.parse(data);
console.log('parsed:', parsed);

对单独的字段做转换,例如name 进行分割,而 age 进行字符串拼接

const UserSchema2 = z
  .object({
    id: z.number(),
    name: z.string(),
    age: z.number().transform((p) => `年龄为${p}`),
  })
  .transform((res) => ({
    ...res,
    nameArr: res.name.split(' '),
  }));
 
/**
 * {
    age: "年龄为123"
    id: 123344555
    name: "Li Yang"
    nameArr: ['Li', 'Yang]
 }
 */
const parsed2 = UserSchema2.parse(data);
console.log('parsed2', parsed2);
```ts