加強你的 TypeScript 驗證:Type Guard?或許該試試 Zod

Enhance Your TypeScript Validation: Type Guard? Perhaps You Should Try Zod!

Errol Lin
iKala 技術部落格
11 min readJun 20, 2023

--

Errol 在 iKala KOL Radar 擔任 senior software engineer 負責網頁前端的部分,
在本篇文章中,我們將分享 KOL Radar 在運行時如何處理網頁前端資料驗證的問題

Background

在現代的前端開發中,TypeScript 是一個極為流行且廣泛應用的技術。作為一種基於 JavaScript 的強型別程式語言,TypeScript 提供了型別檢查和編譯時錯誤檢測,使得前端應用能夠在可靠性以及可維護性和開發效率方面,獲得更高的水準且擁有強大的型別系統,同時也能夠在開發過程中捕捉到潛在的錯誤,從而減少了運行時錯誤的風險。

但通常事實都不是如同想像中的美好,尤其是在使用外部資料時。
因為實際上 TypeScript 是在編譯時運作,而不是在 runtime。

Reddit — r/ProgrammerHumor

Mapping external data to types

假設我們從 API 拿到一個 todo 的資料

{
"id": 1,
"content": "Hit the gym",
"done": false
}

我們為這份資料建立一個 interface,並使用 axios 獲取資料

interface Todo {
id: number
content: string
done: boolean
}

const fetchTodo = async (id: number): Promise<Todo> => {
const response = await axios.get<Todo>(`/todo/${id}`)
return response.data
}

當 API 回傳的結果不如預期,假設回傳的結果是

{
"id": 1,
"content": "Hit the gym",
"done": "true"
}

Todo 這個型別依然會把 `done` 當成 boolean,但 JavaScript 則是將他視為字串。
因為實際上 TypeScript 是在編譯時運作,而不是在 runtime。

如果現在要做點什麼,可能是寫一個驗證的 function 檢查下列幾點。

1. 資料是否為 object
2. id 是否存在且型別是 number
3. content 是否存在且型別是 string
3. done 是否存在且型別是 boolean

const validate = (obj: any): obj is Todo => {
return obj !== null
&& typeof obj === 'object'
&& typeof obj.id === 'number'
&& typeof obj.content === 'string'
&& typeof obj.done === 'boolean'
}

這麼做是可行的,但如果遇到更複雜的型別,我們會很崩潰。

Zod 登場

TypeScript-first schema validation with static type inference - Zod

使用 Zod 你只需要定義 schema 他就能應用於驗證與產生型別。

讓我們回到上面的情境並使用 Zod。
Todo 可以這麼被定義以及推導出型別,並驗證資料。

import {z} from 'zod'

const todoSchema = z.object({
id: z.number(),
content: z.string(),
done: z.boolean()
})

type Todo = z.infer<typeof todoSchema>

const fetchTodo = async (id: number): Todo => {
const response = await axios.get(`/todo/${id}`)
return todoSchema.parse(response.data)
}

我們現在有了 Todo 的型別和驗證過的資料。

我們接著看看另一個情境

現在我們要做一個簡易的登入表單,使用 email & password 登入。 並搭配 React Hook Form

我們也只需要定義登入表單的 schema,就能完成表單驗證的部分。

const loginRequestSchema = z.object({
email: z.string().email(),
password: z.string(),
});

type LoginRequest = z.infer<typeof loginRequestSchema>

const LoginForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginRequest>({
resolver: zodResolver(loginRequestSchema),
});

const submit = (loginData: LoginRequest): void => {
console.log("trigger login action with:", loginData)
};

return (
<form onSubmit={handleSubmit(submit)}>
<input {...register('email')} />
{errors.email?.message && <p>{errors.email?.message}</p>}
<input type="password" {...register('password')} />
{errors.password?.message && <p>{errors.password?.message}</p>}
<input type="submit" />
</form>
);
};

透過上面兩個情境,我們不再害怕 API 回傳的資料格式變動所造成不預期的錯誤,
我們只要定義好 schema 同時也節省了許多驗證使用者輸入資料以及定義型別的工作。

我們接著看看再複雜一點點的情境

現在我們要做一個註冊表單,

這次多了一些規則而且需要填入的資訊也多了一些,需要使用者填入信箱、密碼、名稱、年齡等,

限制了密碼長度最少要 8 個字元,名稱則是介於 2 - 30 個字元,年齡則是非必填的但是填入時需要是大於零的整數

我們也只需要定義註冊表單的 schema,就能完成表單驗證的部分。

const registerRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2).max(30),
age: z.number().int().positive().optional(),
});

type RegisterRequest = z.infer<typeof registerRequestSchema>

const RegisterForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterRequest>({
resolver: zodResolver(registerRequestSchema),
});

const submit = (registerData: RegisterRequest): void => {
console.log("trigger register action with:", registerData)
};

return (
<form onSubmit={handleSubmit(submit)}>
<input {...register('email')} />
{errors.email?.message && <p>{errors.email?.message}</p>}
<input type="password" {...register('password')} />
{errors.password?.message && <p>{errors.password?.message}</p>}
<input {...register('name')} />
{errors.name?.message && <p>{errors.name?.message}</p>}
<input type="number" {...register('age')} />
{errors.age?.message && <p>{errors.age?.message}</p>}
<input type="submit" />
</form>
);
};

實務應用

Typescript to Zod Schema

KOL Radar 也導入了 Zod,在一開始轉換的過程中有使用 transform.tools 提供的工具將既有的 Typescript interface 先初步轉換成 Zod Schema,再根據實際的細節做調整。

結合 RTK Query

KOL Radar 使用 RTK Query 來處理 data fetching and caching。因此除了驗證使用者輸入之外我們也需要驗證 API 資料,因此需要結合 RTK Query。

一開始我們是在 RTK Query 中的 transformResponse 進行資料驗證並在驗證錯誤時噴出錯誤。

builder.query<Example, void>({
query: () => '/example/query',
transformResponse: (response: Example) => {
exampleSchema.parse(response);
return response;
},
});

之後我們使用一個 Higher-Order Function zodBaseQueryWrapper 統一處理 RTK Query 的驗證問題。

在進行 baseQuery 之前,我們會先進行對於傳入參數的驗證,

並且同樣對 baseQuery 回傳的結果進行驗證。

const zodBaseQueryWrapper: BaseQueryEnhancer<
unknown,
{ argumentSchema?: ZodSchema, dataSchema?: ZodSchema }
> = (baseQuery) => async (args, api, extraOptions) => {
if (extraOptions.argumentSchema) {
try {
extraOptions.argumentSchema.parse(args)
} catch (error) {
return { error }
}
}
const returnValue = baseQuery(args, api, extraOptions)
if (extraOptions.dataSchema && 'data' in returnValue) {
try {
extraOptions.dataSchema.parse(returnValue.data)
} catch (error) {
return { error }
}
}
return returnValue
}

不熟悉 RTK Query 也沒關係,這裡主要的部分還是使用 Zod parse 來驗證資料及拋出錯誤。

Comparison

當然也有許多其他的工具提供類似的功能礙於篇幅與主題的緣故再這不一一說明,

官網有寫出相關的內容如果對於以下 package 有興趣的可以看看 Zod 點出的差異。

Comparison with Joi、Yup、io-ts、Runtypes、Ow

Conclusion

首先我們理解了 TypeScript 在 compile 以及 runtime 的差異。 在使用者輸入資料或者是在使用外部資料時,更需要驗證工具來幫助我們。 這時候 Zod 就會是一個很棒且使用成本很低的工具,即使你使用的是 JavaScript 也依然適用!

  • 註:上述內容皆為 untested pseudocode

最後我們現在還有在徵才喔,如果你也是擁抱改變對技術充滿熱情的夥伴,歡迎投遞履歷、來聊聊~

感謝花時間讀這篇文章,如果覺得有得到收穫,不要吝嗇給個 「掌聲鼓勵」,有什麼想討論的也可以留言讓我知道!

--

--