【超详细】Zod 入门教程

Zod 是一个以 TypeScript 为首的模式声明和验证库 ,弥补了 TypeScript 无法在运行时进行校验的问题

Zod 既可以用在服务端也可以运行在客户端,以保障 Web Apps 的类型安全

接下来会用十个有趣的例子,带你快速入门 Zod,体会 Zod 的强大和便利 ~ 感谢 Matt Pocock 大神提供的 示例

本文仓库地址 传送门 👉

提示:本文 Star Wars API 有时会有超时情况,如遇超时则重试几遍哈

01 - 使用 Zod 进行运行时类型校验

问题

TypeScript 是一个非常有用的类型工具,用于检查代码中变量的类型

但是我们不能总是保证代码中变量的类型,比如这些变量来自 API 接口或者表单输入

Zod 库使得我们能够在 运行时 检查变量的类型,它对于我们的大部分项目都是有用的

初探运行时检查

看看这个 toString 函数:

export const toString = (num: unknow) => {
    return String(num)
}

我们将 num 的入参设置为 unknow

这意味着我们可以在编码过程中给 toString 函数传递任何类型的参数,包括 object 类型或者 undefined :

toString('blah')
toString(undefined)
toString({name: 'Matt'})

到目前为止还是没有报错的,但我们想要在 运行时 防止这样的事情发生

如果我们给 toString 传入一个字符串,我们想要抛出一个错误,并提示预期传入一个数字但是接收到一个字符串

it("当入参不是数字的时候,需要抛出一个错误", () => {
  expect(() => toString("123")).toThrowError(
    "Expected number, received string",
  );
});

如果我们传入一个数字,toString 是能够正常运行的

it("当入参是数字的时候,需要返回一个字符串", () => {
    expect(toString(1)).toBeTypeOf("string");
});

解决方案

创建一个 numberParser

各种 Parser 是 Zod 最基础的功能之一

我们通过 z.number() 来创建一个 numberParser

它创建了 z.ZodNumber 对象,这个对象提供了一些有用的方法

const numberParser = z.number();

如果数据不是数字类型的话,那么将这些数据传进 numberParser.parse() 后会报错

这就意味着,所有传进 numberParser.parse() 的变量都会被转成数字,然后我们的测试才能够通过。

添加 numberParser , 更新 toString 方法


const numberParser = z.number();

export const toString = (num: unknown) => {
  const parsed = numberParser.parse(num);
  return String(parsed);
};

尝试不同的类型

Zod 也允许其他的类型检验

比如,如果我们要接收的参数不是数字而是一个 boolean 值,那么我们可以把 numberParser 修改成 z.boolean()

当然,如果我们只修改了这个,那么我们原有的测试用例就会报错哦

Zod 的这种技术为我们提供了坚实的基础。 随着我们的深入使用,你会发现 Zod 模仿了很多你在 TypeScript 中习惯的东西。

可以在 这里 查看 Zod 完整的基础类型

02 - 使用 Object Schema 对未知的 API 进行校验

问题

Zod 经常被用于校验未知的 API 返回内容

在下面这个例子中,我们从 Star Wars API 中获取一个人物的信息

export const fetchStarWarsPersonName = async (id: string) => {
  const data = await fetch("<https://swapi.dev/api/people/>" + id).then((res) =>
    res.json(),
  );

  const parsedData = PersonResult.parse(data);

  return parsedData.name;
};

注意到现在 PersonResult.parser() 处理的数据是从 fetch 请求来的

PersonResult 变量是由 z.unknown() 创建的,这告诉我们数据是被认为是 unknown 类型因为我们不知道这些数据里面包含有什么

const PersonResult = z.unknown();

运行测试

如果我们是用 console.log(data) 打印出 fetch 函数的返回值,我们可以看到这个 API 返回的内容有很多,不仅仅有人物的 name ,还有其他的比如 eye_color,skin_color 等等一些我们不感兴趣的内容

接下来我们需要修复这个 PersonResult 的 unknown 类型

解决方案

使用 z.object 来修改 PersonResult

首先,我们需要将 PersonResult 修改为 z.object

它允许我们使用 key 和类型来定义这些 object

在这个例子中,我们需要定义 name 成为字符串

const PersonResult = z.object({
  name: z.string(),
});

注意到这里有点像我们在 TypeScript 中创建 interface

interface PersonResult {
    name: string;
}

检查我们的工作

fetchStarWarsPersonName 中,我们的 parsedData 现在已经被赋予了正确的类型,并且拥有了一个 Zod 能识别的结构

重新调用 API 我们依然能够看到返回的数据里面有很多我们不感兴趣的信息

现在如果我们用 console.log 打印 parsedData,我们可以看到 Zod 已经帮我们过滤掉我们不感兴趣的 Key 了,只给我们 name 字段

更多

任何额外加入 PersonResult 的 key 都会被添加到 parsedData

能够显式的指明数据中每个 key 的类型是 Zod 中一个非常有用的功能

03 - 创建自定义类型数组

问题

在这个例子中,我们依然使用 Star Wars API,但是这一次我们要拿到 所有 人物的数据

一开始的部分跟我们之前看到的非常类似,StarWarsPeopleResults 变量会被设置为 z.unknown()

const StarWarsPeopleResults = z.unknown();

export const fetchStarWarsPeople = async () => {
  const data = await fetch("https://swapi.dev/api/people/").then((res) =>
    res.json(),
  );

  const parsedData = StarWarsPeopleResults.parse(data);

  return parsedData.results;
};

跟之前类似,添加 console.log(data) 到 fetch 函数中,我们可以看到数组中有很多数据即使我们只对数组中的 name 字段感兴趣

如果这是一个 TypeScript 的 interface,它可能是需要写成这样

interface Results {
  results: {
    name: string;
  }[];
}

作业

通过使用 object schema 更新 StarWarsPeopleResults ,来表示一个 StarWarsPerson 对象的数组

可以参考这里的文档来获得帮助

解决方案

正确的解法就是创建一个对象来饮用其他的对象。在这个例子中,StarWarsPeopleResults 将是一个包含 results 属性的 z.object

关于 results,我们使用 z.array 并提供 StarWarsPerson 作为参数。我们也不用重复写 name: z.string() 这部分了

这个是之前的代码

const StarWarsPeopleResults = z.unknown()

修改之后

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});

如果我们 console.log 这个 parsedData ,我们可以获得期望的数据

像上面这样声明数组的 object 是 z.array() 最常用的的功能一直,特别是当这个 object 已经创建好了。

04 - 提取对象类型

问题

现在我们使用 console 函数将 StarWarsPeopleResults 打印到控制台

const logStarWarsPeopleResults = (data: unknown) => {
  data.results.map((person) => {
    console.log(person.name);
  });
};

再一次,data 的类型是 unknown

为了修复,可能会尝试使用下面这样的做法:

const logStarWarsPeopleResults = (data: typeof StarWarsPeopleResults)

然而这样还是会有问题,因为这个类型代表的是 Zod 对象的类型而不是 StarWarsPeopleResults 类型

作业

更新 logStarWarsPeopleResults 函数去提取对象类型

解决方案

更新这个打印函数

使用 z.infer 并且传递 typeof StarWarsPeopleResults 来修复问题

const logStarWarsPeopleResults = (
  data: z.infer<typeof StarWarsPeopleResults>,
) => {
  ...

现在当我们在 VSCode 中把鼠标 hover 到这个变量上,我们可以看到它的类型是一个包含了 results 的 object

当我们更新了 StarWarsPerson 这个 schema,函数的 data 也会同步更新

这是一个很棒的方式,它做到使用 Zod 在运行时进行类型检查,同时也可以在构建时获取数据的类型

一个替代方案

当然,我们也可以把 StarWarsPeopleResultsType 保存为一个类型并将它从文件中导出

export type StarWarsPeopleResultsType = z.infer<typeof StarWarsPeopleResults>;

logStarWarsPeopleResults 函数则会被更新成这样

const logStarWarsPeopleResults = (data: StarWarsPeopleResultsType) => {
  data.results.map((person) => {
    console.log(person.name);
  });
};

这样别的文件也可以获取到 StarWarsPeopleResults 类型,如果需要的话

05 - 让 schema 变成可选的

问题

Zod 在前端项目中也同样是有用的

在这个例子中,我们有一个函数叫做 validateFormInput

这里 values 的类型是 unknown,这样做是安全的因为我们不是特别了解这个 form 表单的字段。在这个例子中,我们收集了 namephoneNumber 作为 Form 对象的 schema

const Form = z.object({
  name: z.string(),
  phoneNumber: z.string(),
});

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};

目前的状况来说,我们的测试会报错如果 phoneNumber 字段没有被提交

作业

因为 phoneNumber 不总是必要的,需要想一个方案,不管 phoneNumber 是否有提交,我们的测试用例都可以通过

解决方案

在这种情况下,解决方案非常直观!
phoneNumber schema 后面添加 .optional(),我们的测试将会通过

const Form = z.object({ name: z.string(), phoneNumber: z.string().optional(), });

我们说的是, name 字段是一个必填的字符串,phoneNumber 可能是一个字符串或者 undefined

我们不需要再做更多什么额外的事情,让这个 schema 变成可选的就是一个非常不错的方案

06 - 在 Zod 中设置默认值

问题

我们的下一个例子跟之前的很像:一个支持可选值的 form 表单输入校验器

这一次,Form 有一个 repoName 字段和一个可选数组字段 keywords

const Form = z.object({
  repoName: z.string(),
  keywords: z.array(z.string()).optional(),
});

为了使实际表单更容易,我们希望对其进行设置,以便不必传入字符串数组。

作业

修改 Form 使得当 keywords 字段为空的时候,会有一个默认值(空数组)

解决方案

Zod 的 default schema 函数,允许当某个字段没有传参时提供一个默认值

在这个例子中,我们将会使用 .default([]) 设置一个空数组

修改前

keywords: z.array(z.string()).optional()

修改后

keywords: z.array(z.string()).default([])

因为我们添加了默认值,所以我们不需要再使用 optional() ,optional 已经是被包含在其中了。

修改之后,我们的测试可以通过了

输入不同于输出

在 Zod 中,我们已经做到了输入与输出不同的地步。

也就是说,我们可以做到基于输入生成类型也可以基于输出生成类型

比如,我们创建 FormInputFormOutput 类型

type FormInput = z.infer<typeof Form>
type FormOutput = z.infer<typeof Form>

介绍 z.input

就像上面写的,输入不完全正确,因为当我们在给 validateFormInput 传参数时,我们没有必要一定要传递 keywords 字段

我们可以使用 z.input 来替代 z.infer 来修改我们的 FormInput

如果验证函数的输入和输出之间存在差异,则为我们提供了另外一种生成的类型的方法。

type FormInput = z.input<typeof Form>

07 - 明确允许的类型

问题

在这个例子中,我们将再一次校验表单

这一次,Form 表单有一个 privacyLevel 字段,这个字段只允许 private 或者 public 这两个类型

const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.string(),
});

如果是在 TypeScript 中,我们会这么写

type PrivacyLevel = 'private' | 'public'

当然,我们可以在这里使用 boolean 类型,但如果将来我们还需要往 PrivacyLevel 中添加新的类型,那就不太合适了。在这里,使用联合类型或者枚举类型是更加安全的做法。

作业

第一个测试报错了,因为我们的 validateFormInput 函数有除了 "private" 或 "public" 以外的其他值传入 PrivacyLevel 字段

it("如果传入一个非法的 privacyLevel 值,则需要报错", async () => {
  expect(() =>
    validateFormInput({
      repoName: "mattpocock",
      privacyLevel: "something-not-allowed",
    }),
  ).toThrowError();
});

你的任务是要找到一个 Zod 的 API 来允许我们明确入参的字符串类型,以此来让测试能够顺利通过。

解决方案

联合 (Unions) & 字面量 (Literals)

第一个解决方案,我们将使用 Zod 的 联合函数,再传一个包含 "private" 和 "public" 字面量 的数组

const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.union([z.literal("private"), z.literal("public")]),
});

字面量可以用来表示:数字,字符串,布尔类型;不能用来表示对象类型

我们能使用 z.infer 检查我们 Form 的类型

type FormType = z.infer<typeof Form>

在 VS Code 中如果你把鼠标移到 FormType 上,我们可以看到 privacyLevel 有两个可选值:"private" 和 "public"

可认为是更加简洁的方案:枚举

通过 z.enum 使用 Zod 枚举,也可以做到相同的事情,如下:

const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.enum(["private", "public"]),
});

我们可以通过语法糖的方式来解析字面量,而不是使用 z.literal

这个方式不会产生 TypeScript 中的枚举类型,比如

enum PrivacyLevcel {
    private,
    public
}

一个新的联合类型会被创建

同样,我们通过把鼠标移到类型上面,我们可以看到一个新的包含 "private" 和 "public" 的联合类型

08 - 复杂的 schema 校验

问题

到目前为止,我们的表单校验器函数已经可以检查各种值了

表单拥有 name,email 字段还有可选的 phoneNumber 和 website 字段

然而,我们现在想对一些值做强约束

需要限制用户不能输入不合法的 URL 以及电话号码

作业

你的任务是寻找 Zod 的 API 来为表单类型做校验

电话号码需要是合适的字符,邮箱地址和 URL 也需要正确的格式

解决方案

Zod 文档的字符串章节包含了一些校验的例子,这些可以帮助我们顺利通过测试

现在我们的 Form 表单 schema 会是写成这样

const Form = z.object({
  name: z.string().min(1),
  phoneNumber: z.string().min(5).max(20).optional(),
  email: z.string().email(),
  website: z.string().url().optional(),
});

name 字段加上了 min(1),因为我们不能给它传空字符串

phoneNumber 限制了字符串长度是 5 至 20,同时它是可选的

Zod 有内建的邮箱和 url 校验器,我们可以不需要自己手动编写这些规则

可以注意到,我们不能这样写 .optional().min(), 因为optional 类型没有 min 属性。这意味着我们需要将 .optional() 写在每个校验器后面

还有很多其他的校验器规则,我们可以在 Zod 文档中找到

09 - 通过组合 schema 来减少重复

问题

现在,我们来做一些不一样的事情

在这个例子中,我们需要寻找方案来重构项目,以减少重复代码

这里我们有这些 schema,包括:User, PostComment

const User = z.object({
  id: z.string().uuid(),
  name: z.string(),
});

const Post = z.object({
  id: z.string().uuid(),
  title: z.string(),
  body: z.string(),
});

const Comment = z.object({
  id: z.string().uuid(),
  text: z.string(),
});

我们看到, id 在每个 schema 都出现了

Zod 提供了许多方案可以将 object 对象组织到不同的类型中,使得我们可以让我们的代码更加符合 DRY 原则

作业

你的挑战是,需要使用 Zod 进行代码重构,来减少 id 的重复编写

关于测试用例语法

你不用担心这个测试用例的 TypeScript 语法,这里有个快速的解释:

Expect<
  Equal<z.infer<typeof Comment>, { id: string; text: string }>
>

在上面的代码中,Equal 是确认 z.infer<typeof Comment>{id: string; text: string} 是相同的类型

如果你删除掉 Commentid 字段,那么在 VS Code 中可以看到 Expect 会有一个报错,因为这个比较不成立了

解决方案

我们有很多方法可以重构这段代码

作为参考,这是我们开始的内容:

const User = z.object({
  id: z.string().uuid(),
  name: z.string(),
});

const Post = z.object({
  id: z.string().uuid(),
  title: z.string(),
  body: z.string(),
});

const Comment = z.object({
  id: z.string().uuid(),
  text: z.string(),
});

简单的方案

最简单的方案是抽取 id 字段保存成一个单独的类型,然后每一个 z.object 都可以引用它

const Id = z.string().uuid();

const User = z.object({
  id: Id,
  name: z.string(),
});

const Post = z.object({
  id: Id,
  title: z.string(),
  body: z.string(),
});

const Comment = z.object({
  id: Id,
  text: z.string(),
});

这个方案挺不错,但是 id: ID 这段仍然是一直在重复。所有的测试都可以通过,所以也还行

使用扩展(Extend)方法

另一个方案是创建一个叫做 ObjectWithId 的基础对象,这个对象包含 id 字段

const ObjectWithId = z.object({
  id: z.string().uuid(),
});

我们可以使用扩展方法去创建一个新的 schema 来添加基础对象

const ObjectWithId = z.object({
  id: z.string().uuid(),
});

const User = ObjectWithId.extend({
  name: z.string(),
});

const Post = ObjectWithId.extend({
  title: z.string(),
  body: z.string(),
});

const Comment = ObjectWithId.extend({
  text: z.string(),
});

请注意,.extend() 会覆盖字段

使用合并(Merge)方法

跟上面的方案类似,我们可以使用合并方法来扩展基础对象 ObjectWithId :

const User = ObjectWithId.merge(
  z.object({
    name: z.string(),
  }),
);

使用 .merge() 会比 .extend() 更加冗长。我们必须传一个包含 z.string()z.object() 对象

合并通常是用于联合两个不同的类型,而不是仅仅用来扩展单个类型

这些是在 Zod 中将对象组合在一起的几种不同方式,以减少代码重复量,使代码更加符合 DRY,并使项目更易于维护!

10 - 通过 schema 转换数据

问题

Zod 的另一个十分有用的功能是控制从 API 接口响应的数据

现在我们翻回去看看 Star Wars 的例子

想起我们创建了 StarWarsPeopleResults , 其中 results 字段是一个包含 StarWarsPerson schema 的数组

当我们从 API 获取 StarWarsPersonname,我们获取的是他们的全称

现在我们要做的是为 StarWarsPerson 添加转换

作业

你的任务是为这个基础的 StarWarsPerson 对象添加一个转换,将 name 字段按照空格分割成数组,并将数组保存到 nameAsArray 字段中

测试用例大概是这样的:

it("需要解析 name 和 nameAsArray 字段", async () => {
  expect((await fetchStarWarsPeople())[0]).toEqual({
    name: "Luke Skywalker",
    nameAsArray: ["Luke", "Skywalker"],
  });
});

解决方案

提醒一下,这是 StarWarsPerson 在转换前的样子:

const StarWarsPerson = z.object({
  name: z.string()
});

添加一个转换 (Transformation)

当我们在 .object() 中的 name 字段时,我们可以获取 person 参数,然后转换它并添加到一个新的属性中

const StarWarsPerson = z
  .object({
    name: z.string(),
  })
  .transform((person) => ({
    ...person,
    nameAsArray: person.name.split(" "),
  }));

.transform() 内部,person 是上面包含 name 的对象。

这也是我们添加满足测试的 nameAsArray 属性的地方。

所有这些都发生在 StarWarsPerson 这个作用域中,而不是在 fetch 函数内部或其他地方。

另一个例子

Zod 的转换 API 适用于它的任何原始类型。

比如,我们可以转换 namez.object 的内部

const StarWarsPerson = z
  .object({
    name: z.string().transform((name) => `Awesome ${name}`)
  }),
  ...

现在我们拥有一个 name 字段包含 Awesome Luke Skywalker 和一个 nameAsArray 字段包含 ['Awesome', 'Luke', 'Skywalker']

转换过程在最底层起作用,可以组合,并且非常有用

总结

以上就是教程的所有内容,后续还会一直补充更多的实用例子,建议收藏 ~ 也欢迎各位小伙伴看完之后能跟我一起讨论有关于 Zod 的相关问题,提出宝贵意见 ~

引用文献

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容