Zod 是一個(gè)以 TypeScript 為首的模式聲明和驗(yàn)證庫 ,彌補(bǔ)了 TypeScript 無法在運(yùn)行時(shí)進(jìn)行校驗(yàn)的問題
Zod 既可以用在服務(wù)端也可以運(yùn)行在客戶端,以保障 Web Apps 的類型安全
接下來會用十個(gè)有趣的例子,帶你快速入門 Zod,體會 Zod 的強(qiáng)大和便利 ~ 感謝 Matt Pocock 大神提供的 示例
本文倉庫地址 傳送門 ??
提示:本文 Star Wars API 有時(shí)會有超時(shí)情況,如遇超時(shí)則重試幾遍哈
01 - 使用 Zod 進(jìn)行運(yùn)行時(shí)類型校驗(yàn)
問題
TypeScript 是一個(gè)非常有用的類型工具,用于檢查代碼中變量的類型
但是我們不能總是保證代碼中變量的類型,比如這些變量來自 API 接口或者表單輸入
Zod 庫使得我們能夠在 運(yùn)行時(shí)
檢查變量的類型,它對于我們的大部分項(xiàng)目都是有用的
初探運(yùn)行時(shí)檢查
看看這個(gè) toString
函數(shù):
export const toString = (num: unknow) => {
return String(num)
}
我們將 num 的入?yún)⒃O(shè)置為 unknow
這意味著我們可以在編碼過程中給 toString
函數(shù)傳遞任何類型的參數(shù),包括 object 類型或者 undefined :
toString('blah')
toString(undefined)
toString({name: 'Matt'})
到目前為止還是沒有報(bào)錯(cuò)的,但我們想要在 運(yùn)行時(shí)
防止這樣的事情發(fā)生
如果我們給 toString
傳入一個(gè)字符串,我們想要拋出一個(gè)錯(cuò)誤,并提示預(yù)期傳入一個(gè)數(shù)字但是接收到一個(gè)字符串
it("當(dāng)入?yún)⒉皇菙?shù)字的時(shí)候,需要拋出一個(gè)錯(cuò)誤", () => {
expect(() => toString("123")).toThrowError(
"Expected number, received string",
);
});
如果我們傳入一個(gè)數(shù)字,toString
是能夠正常運(yùn)行的
it("當(dāng)入?yún)⑹菙?shù)字的時(shí)候,需要返回一個(gè)字符串", () => {
expect(toString(1)).toBeTypeOf("string");
});
解決方案
創(chuàng)建一個(gè) numberParser
各種 Parser 是 Zod 最基礎(chǔ)的功能之一
我們通過 z.number()
來創(chuàng)建一個(gè) numberParser
它創(chuàng)建了 z.ZodNumber
對象,這個(gè)對象提供了一些有用的方法
const numberParser = z.number();
如果數(shù)據(jù)不是數(shù)字類型的話,那么將這些數(shù)據(jù)傳進(jìn) numberParser.parse()
后會報(bào)錯(cuò)
這就意味著,所有傳進(jìn) numberParser.parse()
的變量都會被轉(zhuǎn)成數(shù)字,然后我們的測試才能夠通過。
添加 numberParser
, 更新 toString
方法
const numberParser = z.number();
export const toString = (num: unknown) => {
const parsed = numberParser.parse(num);
return String(parsed);
};
嘗試不同的類型
Zod 也允許其他的類型檢驗(yàn)
比如,如果我們要接收的參數(shù)不是數(shù)字而是一個(gè) boolean 值,那么我們可以把 numberParser
修改成 z.boolean()
當(dāng)然,如果我們只修改了這個(gè),那么我們原有的測試用例就會報(bào)錯(cuò)哦
Zod 的這種技術(shù)為我們提供了堅(jiān)實(shí)的基礎(chǔ)。 隨著我們的深入使用,你會發(fā)現(xiàn) Zod 模仿了很多你在 TypeScript 中習(xí)慣的東西。
可以在 這里 查看 Zod 完整的基礎(chǔ)類型
02 - 使用 Object Schema 對未知的 API 進(jìn)行校驗(yàn)
問題
Zod 經(jīng)常被用于校驗(yàn)未知的 API 返回內(nèi)容
在下面這個(gè)例子中,我們從 Star Wars API 中獲取一個(gè)人物的信息
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;
};
注意到現(xiàn)在 PersonResult.parser()
處理的數(shù)據(jù)是從 fetch 請求來的
PersonResult
變量是由 z.unknown()
創(chuàng)建的,這告訴我們數(shù)據(jù)是被認(rèn)為是 unknown
類型因?yàn)槲覀儾恢肋@些數(shù)據(jù)里面包含有什么
const PersonResult = z.unknown();
運(yùn)行測試
如果我們是用 console.log(data)
打印出 fetch 函數(shù)的返回值,我們可以看到這個(gè) API 返回的內(nèi)容有很多,不僅僅有人物的 name ,還有其他的比如 eye_color,skin_color 等等一些我們不感興趣的內(nèi)容
接下來我們需要修復(fù)這個(gè) PersonResult
的 unknown 類型
解決方案
使用 z.object
來修改 PersonResult
首先,我們需要將 PersonResult
修改為 z.object
它允許我們使用 key 和類型來定義這些 object
在這個(gè)例子中,我們需要定義 name
成為字符串
const PersonResult = z.object({
name: z.string(),
});
注意到這里有點(diǎn)像我們在 TypeScript 中創(chuàng)建 interface
interface PersonResult {
name: string;
}
檢查我們的工作
在 fetchStarWarsPersonName
中,我們的 parsedData
現(xiàn)在已經(jīng)被賦予了正確的類型,并且擁有了一個(gè) Zod 能識別的結(jié)構(gòu)
重新調(diào)用 API 我們依然能夠看到返回的數(shù)據(jù)里面有很多我們不感興趣的信息
現(xiàn)在如果我們用 console.log
打印 parsedData
,我們可以看到 Zod 已經(jīng)幫我們過濾掉我們不感興趣的 Key 了,只給我們 name
字段
更多
任何額外加入 PersonResult
的 key 都會被添加到 parsedData
中
能夠顯式的指明數(shù)據(jù)中每個(gè) key 的類型是 Zod 中一個(gè)非常有用的功能
03 - 創(chuàng)建自定義類型數(shù)組
問題
在這個(gè)例子中,我們依然使用 Star Wars API,但是這一次我們要拿到 所有
人物的數(shù)據(jù)
一開始的部分跟我們之前看到的非常類似,StarWarsPeopleResults
變量會被設(shè)置為 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 函數(shù)中,我們可以看到數(shù)組中有很多數(shù)據(jù)即使我們只對數(shù)組中的 name 字段感興趣
如果這是一個(gè) TypeScript 的 interface,它可能是需要寫成這樣
interface Results {
results: {
name: string;
}[];
}
作業(yè)
通過使用 object schema 更新 StarWarsPeopleResults
,來表示一個(gè) StarWarsPerson
對象的數(shù)組
可以參考這里的文檔來獲得幫助
解決方案
正確的解法就是創(chuàng)建一個(gè)對象來飲用其他的對象。在這個(gè)例子中,StarWarsPeopleResults
將是一個(gè)包含 results
屬性的 z.object
關(guān)于 results
,我們使用 z.array
并提供 StarWarsPerson
作為參數(shù)。我們也不用重復(fù)寫 name: z.string()
這部分了
這個(gè)是之前的代碼
const StarWarsPeopleResults = z.unknown()
修改之后
const StarWarsPeopleResults = z.object({
results: z.array(StarWarsPerson),
});
如果我們 console.log
這個(gè) parsedData
,我們可以獲得期望的數(shù)據(jù)
像上面這樣聲明數(shù)組的 object 是 z.array()
最常用的的功能一直,特別是當(dāng)這個(gè) object 已經(jīng)創(chuàng)建好了。
04 - 提取對象類型
問題
現(xiàn)在我們使用 console 函數(shù)將 StarWarsPeopleResults 打印到控制臺
const logStarWarsPeopleResults = (data: unknown) => {
data.results.map((person) => {
console.log(person.name);
});
};
再一次,data
的類型是 unknown
為了修復(fù),可能會嘗試使用下面這樣的做法:
const logStarWarsPeopleResults = (data: typeof StarWarsPeopleResults)
然而這樣還是會有問題,因?yàn)檫@個(gè)類型代表的是 Zod 對象的類型而不是 StarWarsPeopleResults
類型
作業(yè)
更新 logStarWarsPeopleResults
函數(shù)去提取對象類型
解決方案
更新這個(gè)打印函數(shù)
使用 z.infer
并且傳遞 typeof StarWarsPeopleResults
來修復(fù)問題
const logStarWarsPeopleResults = (
data: z.infer<typeof StarWarsPeopleResults>,
) => {
...
現(xiàn)在當(dāng)我們在 VSCode 中把鼠標(biāo) hover 到這個(gè)變量上,我們可以看到它的類型是一個(gè)包含了 results 的 object
當(dāng)我們更新了 StarWarsPerson
這個(gè) schema,函數(shù)的 data 也會同步更新
這是一個(gè)很棒的方式,它做到使用 Zod 在運(yùn)行時(shí)進(jìn)行類型檢查,同時(shí)也可以在構(gòu)建時(shí)獲取數(shù)據(jù)的類型
一個(gè)替代方案
當(dāng)然,我們也可以把 StarWarsPeopleResultsType 保存為一個(gè)類型并將它從文件中導(dǎo)出
export type StarWarsPeopleResultsType = z.infer<typeof StarWarsPeopleResults>;
logStarWarsPeopleResults
函數(shù)則會被更新成這樣
const logStarWarsPeopleResults = (data: StarWarsPeopleResultsType) => {
data.results.map((person) => {
console.log(person.name);
});
};
這樣別的文件也可以獲取到 StarWarsPeopleResults
類型,如果需要的話
05 - 讓 schema 變成可選的
問題
Zod 在前端項(xiàng)目中也同樣是有用的
在這個(gè)例子中,我們有一個(gè)函數(shù)叫做 validateFormInput
這里 values
的類型是 unknown
,這樣做是安全的因?yàn)槲覀儾皇翘貏e了解這個(gè) form 表單的字段。在這個(gè)例子中,我們收集了 name
和 phoneNumber
作為 Form
對象的 schema
const Form = z.object({
name: z.string(),
phoneNumber: z.string(),
});
export const validateFormInput = (values: unknown) => {
const parsedData = Form.parse(values);
return parsedData;
};
目前的狀況來說,我們的測試會報(bào)錯(cuò)如果 phoneNumber 字段沒有被提交
作業(yè)
因?yàn)?phoneNumber 不總是必要的,需要想一個(gè)方案,不管 phoneNumber 是否有提交,我們的測試用例都可以通過
解決方案
在這種情況下,解決方案非常直觀!
在 phoneNumber
schema 后面添加 .optional()
,我們的測試將會通過
const Form = z.object({ name: z.string(), phoneNumber: z.string().optional(), });
我們說的是, name
字段是一個(gè)必填的字符串,phoneNumber
可能是一個(gè)字符串或者 undefined
我們不需要再做更多什么額外的事情,讓這個(gè) schema 變成可選的就是一個(gè)非常不錯(cuò)的方案
06 - 在 Zod 中設(shè)置默認(rèn)值
問題
我們的下一個(gè)例子跟之前的很像:一個(gè)支持可選值的 form 表單輸入校驗(yàn)器
這一次,Form
有一個(gè) repoName
字段和一個(gè)可選數(shù)組字段 keywords
const Form = z.object({
repoName: z.string(),
keywords: z.array(z.string()).optional(),
});
為了使實(shí)際表單更容易,我們希望對其進(jìn)行設(shè)置,以便不必傳入字符串?dāng)?shù)組。
作業(yè)
修改 Form
使得當(dāng) keywords
字段為空的時(shí)候,會有一個(gè)默認(rèn)值(空數(shù)組)
解決方案
Zod 的 default schema 函數(shù),允許當(dāng)某個(gè)字段沒有傳參時(shí)提供一個(gè)默認(rèn)值
在這個(gè)例子中,我們將會使用 .default([])
設(shè)置一個(gè)空數(shù)組
修改前
keywords: z.array(z.string()).optional()
修改后
keywords: z.array(z.string()).default([])
因?yàn)槲覀兲砑恿四J(rèn)值,所以我們不需要再使用 optional()
,optional 已經(jīng)是被包含在其中了。
修改之后,我們的測試可以通過了
輸入不同于輸出
在 Zod 中,我們已經(jīng)做到了輸入與輸出不同的地步。
也就是說,我們可以做到基于輸入生成類型也可以基于輸出生成類型
比如,我們創(chuàng)建 FormInput
和 FormOutput
類型
type FormInput = z.infer<typeof Form>
type FormOutput = z.infer<typeof Form>
介紹 z.input
就像上面寫的,輸入不完全正確,因?yàn)楫?dāng)我們在給 validateFormInput
傳參數(shù)時(shí),我們沒有必要一定要傳遞 keywords
字段
我們可以使用 z.input
來替代 z.infer
來修改我們的 FormInput
如果驗(yàn)證函數(shù)的輸入和輸出之間存在差異,則為我們提供了另外一種生成的類型的方法。
type FormInput = z.input<typeof Form>
07 - 明確允許的類型
問題
在這個(gè)例子中,我們將再一次校驗(yàn)表單
這一次,F(xiàn)orm 表單有一個(gè) privacyLevel
字段,這個(gè)字段只允許 private
或者 public
這兩個(gè)類型
const Form = z.object({
repoName: z.string(),
privacyLevel: z.string(),
});
如果是在 TypeScript 中,我們會這么寫
type PrivacyLevel = 'private' | 'public'
當(dāng)然,我們可以在這里使用 boolean 類型,但如果將來我們還需要往 PrivacyLevel
中添加新的類型,那就不太合適了。在這里,使用聯(lián)合類型或者枚舉類型是更加安全的做法。
作業(yè)
第一個(gè)測試報(bào)錯(cuò)了,因?yàn)槲覀兊?validateFormInput
函數(shù)有除了 "private" 或 "public" 以外的其他值傳入 PrivacyLevel
字段
it("如果傳入一個(gè)非法的 privacyLevel 值,則需要報(bào)錯(cuò)", async () => {
expect(() =>
validateFormInput({
repoName: "mattpocock",
privacyLevel: "something-not-allowed",
}),
).toThrowError();
});
你的任務(wù)是要找到一個(gè) Zod 的 API 來允許我們明確入?yún)⒌淖址愋停源藖碜寽y試能夠順利通過。
解決方案
聯(lián)合 (Unions) & 字面量 (Literals)
第一個(gè)解決方案,我們將使用 Zod 的 聯(lián)合函數(shù),再傳一個(gè)包含 "private" 和 "public" 字面量 的數(shù)組
const Form = z.object({
repoName: z.string(),
privacyLevel: z.union([z.literal("private"), z.literal("public")]),
});
字面量可以用來表示:數(shù)字,字符串,布爾類型;不能用來表示對象類型
我們能使用 z.infer
檢查我們 Form
的類型
type FormType = z.infer<typeof Form>
在 VS Code 中如果你把鼠標(biāo)移到 FormType 上,我們可以看到 privacyLevel
有兩個(gè)可選值:"private" 和 "public"
可認(rèn)為是更加簡潔的方案:枚舉
通過 z.enum
使用 Zod 枚舉,也可以做到相同的事情,如下:
const Form = z.object({
repoName: z.string(),
privacyLevel: z.enum(["private", "public"]),
});
我們可以通過語法糖的方式來解析字面量,而不是使用 z.literal
這個(gè)方式不會產(chǎn)生 TypeScript 中的枚舉類型,比如
enum PrivacyLevcel {
private,
public
}
一個(gè)新的聯(lián)合類型會被創(chuàng)建
同樣,我們通過把鼠標(biāo)移到類型上面,我們可以看到一個(gè)新的包含 "private" 和 "public" 的聯(lián)合類型
08 - 復(fù)雜的 schema 校驗(yàn)
問題
到目前為止,我們的表單校驗(yàn)器函數(shù)已經(jīng)可以檢查各種值了
表單擁有 name,email 字段還有可選的 phoneNumber 和 website 字段
然而,我們現(xiàn)在想對一些值做強(qiáng)約束
需要限制用戶不能輸入不合法的 URL 以及電話號碼
作業(yè)
你的任務(wù)是尋找 Zod 的 API 來為表單類型做校驗(yàn)
電話號碼需要是合適的字符,郵箱地址和 URL 也需要正確的格式
解決方案
Zod 文檔的字符串章節(jié)包含了一些校驗(yàn)的例子,這些可以幫助我們順利通過測試
現(xiàn)在我們的 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)
,因?yàn)槲覀儾荒芙o它傳空字符串
phoneNumber
限制了字符串長度是 5 至 20,同時(shí)它是可選的
Zod 有內(nèi)建的郵箱和 url 校驗(yàn)器,我們可以不需要自己手動編寫這些規(guī)則
可以注意到,我們不能這樣寫 .optional().min()
, 因?yàn)閛ptional 類型沒有 min
屬性。這意味著我們需要將 .optional()
寫在每個(gè)校驗(yàn)器后面
還有很多其他的校驗(yàn)器規(guī)則,我們可以在 Zod 文檔中找到
09 - 通過組合 schema 來減少重復(fù)
問題
現(xiàn)在,我們來做一些不一樣的事情
在這個(gè)例子中,我們需要尋找方案來重構(gòu)項(xiàng)目,以減少重復(fù)代碼
這里我們有這些 schema,包括:User
, Post
和 Comment
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 在每個(gè) schema 都出現(xiàn)了
Zod 提供了許多方案可以將 object 對象組織到不同的類型中,使得我們可以讓我們的代碼更加符合 DRY
原則
作業(yè)
你的挑戰(zhàn)是,需要使用 Zod 進(jìn)行代碼重構(gòu),來減少 id 的重復(fù)編寫
關(guān)于測試用例語法
你不用擔(dān)心這個(gè)測試用例的 TypeScript 語法,這里有個(gè)快速的解釋:
Expect<
Equal<z.infer<typeof Comment>, { id: string; text: string }>
>
在上面的代碼中,Equal
是確認(rèn) z.infer<typeof Comment>
和 {id: string; text: string}
是相同的類型
如果你刪除掉 Comment
的 id
字段,那么在 VS Code 中可以看到 Expect
會有一個(gè)報(bào)錯(cuò),因?yàn)檫@個(gè)比較不成立了
解決方案
我們有很多方法可以重構(gòu)這段代碼
作為參考,這是我們開始的內(nèi)容:
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
字段保存成一個(gè)單獨(dú)的類型,然后每一個(gè) 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(),
});
這個(gè)方案挺不錯(cuò),但是 id: ID
這段仍然是一直在重復(fù)。所有的測試都可以通過,所以也還行
使用擴(kuò)展(Extend)方法
另一個(gè)方案是創(chuàng)建一個(gè)叫做 ObjectWithId
的基礎(chǔ)對象,這個(gè)對象包含 id
字段
const ObjectWithId = z.object({
id: z.string().uuid(),
});
我們可以使用擴(kuò)展方法去創(chuàng)建一個(gè)新的 schema 來添加基礎(chǔ)對象
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)方法
跟上面的方案類似,我們可以使用合并方法來擴(kuò)展基礎(chǔ)對象 ObjectWithId
:
const User = ObjectWithId.merge(
z.object({
name: z.string(),
}),
);
使用 .merge()
會比 .extend()
更加冗長。我們必須傳一個(gè)包含 z.string()
的 z.object()
對象
合并通常是用于聯(lián)合兩個(gè)不同的類型,而不是僅僅用來擴(kuò)展單個(gè)類型
這些是在 Zod 中將對象組合在一起的幾種不同方式,以減少代碼重復(fù)量,使代碼更加符合 DRY,并使項(xiàng)目更易于維護(hù)!
10 - 通過 schema 轉(zhuǎn)換數(shù)據(jù)
問題
Zod 的另一個(gè)十分有用的功能是控制從 API 接口響應(yīng)的數(shù)據(jù)
現(xiàn)在我們翻回去看看 Star Wars 的例子
想起我們創(chuàng)建了 StarWarsPeopleResults
, 其中 results
字段是一個(gè)包含 StarWarsPerson
schema 的數(shù)組
當(dāng)我們從 API 獲取 StarWarsPerson
的 name
,我們獲取的是他們的全稱
現(xiàn)在我們要做的是為 StarWarsPerson
添加轉(zhuǎn)換
作業(yè)
你的任務(wù)是為這個(gè)基礎(chǔ)的 StarWarsPerson
對象添加一個(gè)轉(zhuǎn)換,將 name
字段按照空格分割成數(shù)組,并將數(shù)組保存到 nameAsArray
字段中
測試用例大概是這樣的:
it("需要解析 name 和 nameAsArray 字段", async () => {
expect((await fetchStarWarsPeople())[0]).toEqual({
name: "Luke Skywalker",
nameAsArray: ["Luke", "Skywalker"],
});
});
解決方案
提醒一下,這是 StarWarsPerson
在轉(zhuǎn)換前的樣子:
const StarWarsPerson = z.object({
name: z.string()
});
添加一個(gè)轉(zhuǎn)換 (Transformation)
當(dāng)我們在 .object()
中的 name
字段時(shí),我們可以獲取 person
參數(shù),然后轉(zhuǎn)換它并添加到一個(gè)新的屬性中
const StarWarsPerson = z
.object({
name: z.string(),
})
.transform((person) => ({
...person,
nameAsArray: person.name.split(" "),
}));
在 .transform()
內(nèi)部,person
是上面包含 name
的對象。
這也是我們添加滿足測試的 nameAsArray
屬性的地方。
所有這些都發(fā)生在 StarWarsPerson
這個(gè)作用域中,而不是在 fetch
函數(shù)內(nèi)部或其他地方。
另一個(gè)例子
Zod 的轉(zhuǎn)換 API 適用于它的任何原始類型。
比如,我們可以轉(zhuǎn)換 name
在 z.object
的內(nèi)部
const StarWarsPerson = z
.object({
name: z.string().transform((name) => `Awesome ${name}`)
}),
...
現(xiàn)在我們擁有一個(gè) name
字段包含 Awesome Luke Skywalker
和一個(gè) nameAsArray
字段包含 ['Awesome', 'Luke', 'Skywalker']
轉(zhuǎn)換過程在最底層起作用,可以組合,并且非常有用
總結(jié)
以上就是教程的所有內(nèi)容,后續(xù)還會一直補(bǔ)充更多的實(shí)用例子,建議收藏 ~ 也歡迎各位小伙伴看完之后能跟我一起討論有關(guān)于 Zod 的相關(guān)問題,提出寶貴意見 ~