iT邦幫忙

2023 iThome 鐵人賽

DAY 16
1
SideProject30

營養師不開菜單要用 Next.js 13 寫全端系列 第 16

營養師不開菜單的第十六天 - TypeScript 不夠?使用 Zod 做型別驗證

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20230930/20152073bXHUlGYjxT.png

在上篇文章中,我已稍微提及了本次專案所使用的驗證工具——Zod。Zod 是專為 TypeScript 設計的資料驗證工具,就像人體的免疫系統,確保資料結構在 runtime 時與 TypeScript 的靜態型別一致,彌補了 TypeScript 僅在編譯時檢查靜態型別的不足。Zod 主打的特色是可以藉由設置驗證的 Schema ,並通過 TypeScript 的型別推斷功能,自動產生相對應的型別,這避免了開發者重複手動定義型別的需要。除此之外,Zod 還具備一套完整的錯誤訊息系統,大大減少了開發者需要自行定義驗證邏輯和錯誤處理的複雜性。

為什麼要用 Zod ?

  1. 輕量級且無額外依賴:沒有外部依賴,可以使套件更輕巧且易於安裝和維護。
  2. 與 react-form-hook 可以無痛結合使用
  3. 數值預設為 required:以 TypeScript 的思考邏輯,若值可以為 null 或 undefined 再額外設定。
  4. 自定義錯誤訊息:可為 Schema 的每一部分提供自定義的錯誤訊息,增加開發效率。
  5. 自動推斷型別:可以設定的 Schema 中自動生成 TypeScript 的型別,而不需要手動維護它們。
  6. 驗證外來資料:可用 parsesafeParse 驗證和確保外來資料的正確型別。

Zod 可以怎麼用?


如果熟悉 TypeScript 的使用邏輯,其實在使用 Zod 時會覺得格外親切,例如我想要定義標單中的驗證及型別

基礎方法

先定義 scheme 再轉換為 type。

特別注意因為 TypeScript 的型別規則,所以 Zod 預設就是 required 屬性

const formValuesSchema = z.object({
  customImage: z.string(),
  title: z.string(),
  description: z.string(),
  themeColor: z.enum(['basic', 'blue-rose', 'lime'])
})

type FormValues = z.infer<typeof formValuesSchema>

上方範例中我的 FormValues 就會是:

type FormValues = {
    title: string;
    customImage: string;
    description: string;
    themeColor: "basic" | "blue-rose" | "lime";
}

錯誤訊息設置

設定好了規則,我們還需要設定錯誤訊息,

  • 必填錯誤訊息:required_error
  • 型別錯誤訊息:invalid_type_error
const loginSchema = z.object({
  email: z.string({
    required_error: 'Email 為必填欄位',
    invalid_type_error: '必須為字串'
  }),
  password: z.string({ required_error: 'Password 為必填欄位' })
})

想要依據不同狀態設置不同訊息,Zod 也提供 errorMap 屬性可以使用

issue code 可以參考:https://github.com/colinhacks/zod/blob/master/ERROR_HANDLING.md#error-handling-in-zod

const loginSchema = z.object({
  email: z.string({
    required_error: 'Email 為必填欄位',
    invalid_type_error: '必須為字串',
    description: '請輸入正確的 Email',
    errorMap: (issue, ctx) => {
      if (issue.code === z.ZodIssueCode.invalid_type) {
        if (issue.received === 'undefined') { 
          return { message: 'Email 為必填欄位' }
        }
        if (issue.received === 'number') {
          return { message: '不可為數字' }
        }
      }
      return { message: ctx.defaultError }
    }
  }),
  password: z.string({ required_error: 'Password 為必填欄位' })
})

想設置型別的特殊驗證規則?

假如我希望 string、number 等型別可以有像是長度或是特殊規格,Zod 也有提供許多常用方法:

const signUpSchema = z.object({
  username: z
    .string({ required_error: 'Username 為必填欄位' })
    .regex(/^[a-zA-Z0-9_]*$/),
  email: z
    .string({ required_error: 'Email 為必填欄位' }).email(),
  password: z
    .string({ required_error: 'Password 為必填欄位' }).min(8)
})

另外看到很特別的有 url()emoji()ip()includes(string)

number 類型則有 gt(number)gte(number)lt(number)gt(number)lte(number)positive()negative() 等方法

特殊規則的錯誤訊息

一樣將 message 寫在參數中即可

const signUpSchema = z.object({
  username: z
    .string({ required_error: 'Username 為必填欄位' })
    .regex(
      /^[a-zA-Z0-9_]*$/,
      '只能包含英文、數字及底線,不可包含空白及特殊符號'
    ),
  email: z
    .string({ required_error: 'Email 為必填欄位' })
    .email('請輸入正確的 Email'),
  password: z
    .string({ required_error: 'Password 為必填欄位' })
    .min(8, '密碼長度不可小於 8 個字元')
})

https://ithelp.ithome.com.tw/upload/images/20230930/20152073y2Ww360czx.png

如果數值希望可以 null 或 undefined?

Zod 提供了兩種 null 的方法

  • nullable() :數值可以為 null
  • nullish() :數值可以為 null 或 undefined
const schema = z.object({
  customImage: z.string().url().nullable(),
  title: z.string().nullable(),
  description: z.string().max(300, '字數不可超過 300 字').nullable(),
  themeColor: z.enum(['basic', 'blue-rose', 'lime'])
})

Enum 怎麼用?

我的範例中有使用到 enum 的型別,Zod 除了提供自己的方法也可以使用原生的 enum 設定

原生方法

使用 nativeEnum 定義 schema

enum ThemeColor {
	BASIC = 'basic',
	BLUE_ROSE = 'blue-rose',
	LIME = 'lime'
}

const themeColor = z.nativeEnum(ThemeColor)

Zod 方法

const themeColor = z.enum(['basic', 'blue-rose', 'lime'])

// 或是

const COLORS = ['basic', 'blue-rose', 'lime'] as const
const themeColor = z.enum(COLORS)

如何捕捉錯誤型別並改為預設的數值?

Zod 提供一個方法 - catch() 可以將錯誤的值替換為希望的值

範例說明:如果 themeColor 不為 'basic', 'blue-rose', 'lime',設為 ‘basic’

const schema = z.object({
  customImage: z.string().url().nullable(),
  title: z.string().nullable(),
  description: z.string().max(300, '字數不可超過 300 字').nullable(),
  themeColor: z.enum(['basic', 'blue-rose', 'lime']).catch('basic')
})

Record 使用

如同 typeScript 的 Record 泛型,Zod 也可以使用這個方法。第一個參數為 key 的型別,第二參數為 value 型別

const genericFieldsSchema = z.record(z.string(), z.string().nullable())

聯合型別

可以使用 union()merge()extends()

union()

建立一個聯合型別(union type)

const signUpSchema = z.object({
  username: z
    .string({ required_error: 'Username 為必填欄位' })
    .regex(/^[a-zA-Z0-9_]*$/),
  email: z
    .string({ required_error: 'Email 為必填欄位' }).email(),
  password: z
    .string({ required_error: 'Password 為必填欄位' }).min(8)
})
const genericFieldsSchema = z.record(z.string(), z.string().nullable())
const unionSchema = z.union([signUpSchema, genericFieldsSchema])

extend()

可以從一個現有的 schema 繼承,並添加或覆蓋 field。

const baseSchema = z.object({
  username: z
    .string({ required_error: 'Username 為必填欄位' })
    .regex(/^[a-zA-Z0-9_]*$/),
	password: z
	    .string({ required_error: 'Password 為必填欄位' }).min(8)
});

const extendedSchema = baseSchema.extend({
	 email: z
	    .string({ required_error: 'Email 為必填欄位' }).email(),
});

merge()

合併兩個 schema,但不支持 field 的覆蓋

const baseSchema = z.object({
  username: z
    .string({ required_error: 'Username 為必填欄位' })
    .regex(/^[a-zA-Z0-9_]*$/),
	password: z
	    .string({ required_error: 'Password 為必填欄位' }).min(8)
});

const emailSchema = z.object({
	 email: z.string({ required_error: 'Email 為必填欄位' }).email(),
});

const mergedSchema = baseSchema.merge(emailSchema);

我想要自定義規則!

Zod 當然也提供了除了正則以外的自定義規則方法 - refine()superRefine()

  • refine:回傳一個布林值來表示驗證是否成功,第一個參數為自定義驗證的 callback,第二個參數則是錯誤訊息。
  • superRefine:callback 提供兩個參數:
    1. value: 這是目前正在驗證的值(下圖中是整個 schema )
    2. **context (ctx):**提供了 addIssue 和 path 屬性,讓開發者自定義。

⚠️ 特別注意如果回傳錯誤請使用 ctx.addIssue() 先判斷錯誤類型再輸入錯誤訊息進行錯誤處理,而不是直接回傳布林值!

const schema = z
  .object({
    id: z.string().nullable(),
    title: z.string().nullable(),
    url: z.string().url().nullable(),
    type: z
      .record(z.string(), z.string())
      .catch({ id: 'default', label: '請選擇' }),
    order: z.number().int().nullable()
  })
  .superRefine((val, ctx) => {
    if (val.type.id === 'website') {
      if (!val.title)
        return ctx.addIssue({ // 使用 ctx.addIssue 做錯誤處理
          code: z.ZodIssueCode.custom, // 使用自定義的錯誤類型
          message: '請輸入連結名稱' // 錯誤訊息
        })
    }
  })

想要驗證外來的資料格式

通常從 API 回傳的資料型別會是 unknown ,所以我們可以藉由 Zod 提供的 parse 和 safeParse 進行檢驗

  • parse:
    • 驗證成功:回傳驗證過的資料。
    • 驗證失敗:會拋出一個 ZodError。這表示需要在使用 parse 時使用 try/catch 來處理錯誤。
  • safeParse:回傳一個物件,包含一個 success 布林值,表示驗證是否成功。
    • 如果 successtrue,回傳的物件也包含一個 data 屬性,該屬性包含驗證過的資料。
    • 如果 successfalse,回傳的物件也包含一個 error 屬性,是一個 ZodError 實例,顯示驗證失敗的原因。
// currentUser 是由後端回傳的資料

const themeColorSchema = z.enum(['basic', 'blue-rose', 'lime'])

const currentUserSchema = z.object({
  username: z.string(),
  customImage: z.string().nullable(),
  image: z.string().nullable(),
  title: z.string().nullable(),
  description: z.string().nullable(),
  themeColor: themeColorSchema
})

type CurrentUser = z.infer<typeof currentUserSchema>

const result = currentUserSchema.safeParse(user).success // true or false

封裝成 function

const isValidCurrentUser = (user: unknown): user is CurrentUser => {
  return currentUserSchema.safeParse(user).success
}

if (!isValidCurrentUser(currentUser)) {
    return <div>Something went wrong</div>
  }

結尾

起初想要使用是因為可以進行表單驗證,但越深入學習越發現他的功能讓開發省了很多程序,也慢慢了解這個工具越來越火紅的道理,這篇文章羅列了一些在專案中使用到的功能,也是在這次開發中新學習到的工具,還有很多其他的功能沒敘述到,也可以在日後慢慢研究。最後想要分享一個關於結合 react-form-hook 的型別問題:

❓在自定義 Input 元件中,我將 react-form-hook 的 register 作為參數傳進來,型別定義為 register: UseFormRegister<FieldValues>UseFormRegisterFieldValues 皆由 react-form-hook 提供,其中 FieldValues 的型別為

interface FieldValues {
  [x: string]: any
}

以下是 useForm 的 value 定義的 schema

const schema = z.object({
  customImage: z.string().url().nullable(),
  title: z.string().nullable(),
  description: z.string().max(300, '字數不可超過 300 字').nullable(),
  themeColor: z.enum(['basic', 'blue-rose', 'lime']).catch('basic')
})

type FormValues = z.infer<typeof schema>

這時候問題就出現了:type FieldValues = z.infer<typeof schema> 與 Input 元件中的 FieldValues 型別不符合

https://ithelp.ithome.com.tw/upload/images/20230930/20152073mfwmWdktqB.png

所以解決的方法是將 FormValues 也加上 FieldValues 的型別:

定義為 genericFieldsSchema

const schema = z.object({
  customImage: z.string().url().nullable(),
  title: z.string().nullable(),
  description: z.string().max(300, '字數不可超過 300 字').nullable(),
  themeColor: z.enum(['basic', 'blue-rose', 'lime']).catch('basic')
})
const genericFieldsSchema = z.record(z.string(), z.string().nullable())
const unionSchema = z.union([schema, genericFieldsSchema])

type FormValues = z.infer<typeof unionSchema>

這是目前想到的解決方法,分享給如果有遇到相同情況的讀者們,如果有更好的方法也歡迎提供!!那麼今日份的進度就到驗證結束了~

參考資料

https://ithelp.ithome.com.tw/articles/10305282

https://zod.dev/?id=refine

https://ithelp.ithome.com.tw/upload/images/20230930/20152073S34IfRUtNW.png


上一篇
營養師不開菜單的第十五天 - 為什麼從 Formik 跳槽到 React-Hook-Form
下一篇
營養師不開菜單的第十七天 - 在 Next.js 中使用 Cloudinary CDN 管理媒體資源
系列文
營養師不開菜單要用 Next.js 13 寫全端30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
mikehsu0618
iT邦新手 2 級 ‧ 2023-10-01 11:49:38

先收藏(已經開始追營養小知識了

我要留言

立即登入留言