Skip to main content

Zod 上手筆記

這篇筆記的目的是記錄一些 zod 的常用操作,幫助開發者理解 zod 如何使用。

Zod 用來解決什麼問題?

把 raw input,轉換成符合驗證條件的 typed output

什麼是 raw input

JS 的變數,如 primitive 與 object

// Primitive
const food = "tuna";
const amount = 12; // number
const enable = true;
const bigAmount = 12n; // bigint
const field1 = null;
const field2 = undefined;

const date = Date();
const obj = { firstName: "", lastName: "" };
const arr = [];

什麼是 typed output

通過 zod 剖析後的結果,除了回傳的物件本身是驗證過的之外,還會自帶正確的 typescript 型別。

import { z } from "zod";

// 布林值
const enableSchema = z.boolean();
const enable = enableSchema.parse(true);

// 符合 email 格式的字串
const emailSchema = z.string().email();
const email = emailSchema.parse("abc@gmail.com");

// 符合 ethereum address 格式的字串
const addressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
const address = addressSchema.parse(
"0x1234567890123456789012345678901234567890",
);

// 日期物件
const dateSchema = z.date();
const date = dateSchema.parse(new Date());

// Big 物件(假設使用 big.js)
import { Big } from "big.js";
const bigSchema = z.instanceof(Big);
const big = bigSchema.parse(new Big(123.45));

什麼是驗證條件?

任何你想得到的驗證

  • 例如檢查 number 在規定的範圍內
  • 檢查字串符合 email 的格式

什麼是轉換(Transform)

舉些例子

  • 將驗證後的字串變成小寫
  • 將 string 變成 number
  • 將 ISO8601 string 變成日期物件

學習要點

  1. 如何把 raw input 轉換成符合驗證條件的 typed output
  2. raw input 的 typing 是怎麼決定的?
  3. typed output 的 typing 是怎麼決定的?
  4. 怎麼定義驗證條件?
  5. 怎麼進行轉換?

Number 範例

從一個簡單的 number 範例開始。

const ageSchema = z.number().gte(18);

const age = ageSchema.parse(21);
const ageResult = ageSchema.safeParse(21);

ageSchema 規範輸入是 number,輸出也是 number,而且要求該 number 要 ≥ 18。

  • parse
    • 成功時會直接回傳 parse 成功的結果。
    • 失敗時會 throw ZodError
.parse(data: unknown): T
  • safeParse
    • 失敗不會 throw ZodError,回傳一個 parse 結果的物件表示成功或失敗
.safeParse(data:unknown): { success: true; data: T; } | { success: false; error: ZodError; }

添加更多驗證

z.number().gt(5).lt(10);

自訂錯誤訊息

z.number().lte(5, { message: "this👏is👏too👏big" });

接受 number 外的的 Input

z.number() 要求 input 是 number。但時常我們會遇到 input 是字串而非 number。

const ageSchema = z.number().gte(18);

ageSchema.parse("21"); // throw error, input type is not number

我們可以使用 z.coerce,會強制 input 都套用原生型別的 constructor,Number(input),再進行後續的處理。

const ageSchema2 = z.coerce.number().gte(18); // Number(input)

ageSchema.parse("21"); // return 21

⚠️ coerce 因為直接使用 built-in constructor 轉換,在遇到 null, undefined 等輸入時,並不會報錯。舉例來說,Number(null) 會回傳 0,而不是 throw error。 如果你想要將 null, undefined 視為錯誤,你會需要 .pipe

String 範例

現在我們把學到的內容推廣到 string。

z.string() 要求 input 是 string,輸出也是 string,同時要滿足string length = 5 的驗證條件。

const labelSchema = z.string().length(5);

const label = labelSchema.parse("abcde"); // label = "abcde"

label.parse(12345); // throw ZodError, Expected string, received number

接受 string 外的 Input

coerce 就是讓 input 強制套用 built-in constructor,因此 input 會先被轉換成 String(input)

const labelSchema = z.coerce.string().length(5);

const label = label.parse(12345); // String(12345), then verify length
// label = "12345"

更進階的 string transform

將 string 轉換成小寫

一個常見的需求是,驗證完成後,改變輸入內容。像是改變大小寫、trim 掉空白,這個步驟稱為 transform。

const label = z.string().toLowerCase();
const str = label.parse("AbC"); // str = abc

將 string 轉換成 number

const toNumberSchema = z.coerce.number();

const n = toNumberSchema.parse("123"); // n = 123

將 string 轉換成 date 物件

// string 必須符合 ISO8601,預設不允許 timezone offset
const datetime = z.string().datetime();

datetime.parse("2020-01-01T00:00:00Z"); // pass
datetime.parse("2020-01-01T00:00:00.123Z"); // pass
datetime.parse("2020-01-01T00:00:00.123456Z"); // pass (arbitrary precision)
datetime.parse("2020-01-01T00:00:00+02:00"); // fail (no offsets allowed)

// Timezone offsets can be allowed by setting the offset option to true.
const datetime = z.string().datetime({ offset: true });

datetime.parse("2020-01-01T00:00:00+02:00"); // pass
datetime.parse("2020-01-01T00:00:00.123+02:00"); // pass (millis optional)
datetime.parse("2020-01-01T00:00:00.123+0200"); // pass (millis optional)
datetime.parse("2020-01-01T00:00:00.123+02"); // pass (only offset hours)
datetime.parse("2020-01-01T00:00:00Z"); // pass (Z still supported)

將 string 轉換成 Luxon 的 Date 物件

const luxonDatetime = z.string().transform((val, ctx) => {
try {
return DateTime.fromISO(val); // 轉換成當地時區觀點的 Date 物件
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "parse string to Date failed",
fatal: true,
});
return z.NEVER;
}
});

const datetime = luxonDatetime.parse("2020-01-01T00:00:00Z"); // pass

// DateTime { ts: 2020-01-01T08:00:00.000+08:00, zone: Asia/Taipei, locale: en-US }

將 string 轉換成 Big

export const zodStringToBig = z.string().transform((val, ctx) => {
try {
return Big(val);
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "parse string to Big failed",
fatal: true,
});
return z.NEVER;
}
});

const big = zodStringToBig.parse("9999999999999999999999"); // pass

將 string 轉換成自定義 typing

舉例來說,將驗證後的 string 更加精確的定義成 Address type。這裡的 Address 是指乙太坊的地址。

import { Address, isAddress } from "viem";

export const strictAddressSchema = z.custom<Address>((val) => {
return typeof val === "string" ? isAddress(val) : false;
}, "input is not a valid checksum address or a lower case address");

export const addressSchema = z.custom<Address>((val) => {
return typeof val === "string" ? isAddress(val, { strict: false }) : false;
}, "input is not a valid address");
const checksumAddress = strictAddressSchema.parse(
"0xa5cc3c03994DB5b0d9A5eEdD10CabaB0813678AC",
);

// 你還可以基於現有的 schema 增添行為
const lowerCaseAddress = strictAddressSchema
.transform((addr) => addr.toLowerCase() as Address)
.parse("0xa5cc3c03994DB5b0d9A5eEdD10CabaB0813678AC");

Zod 的處理流程

理解 Transform 與 Refine 應該就足以應付大多數的狀況

.transform

根據文件,zod 的處理流程是

  1. (optional) preprocess
  2. parse & validate
  3. (optional) transform

.preprocess 是舊版常出現的方法,但新版通常可以被 z.coerce 取代掉

const castToString = z.preprocess((val) => String(val), z.string());
// is similar to
z.coerce.string();

.transform 在 zod 內有兩個用途

  1. 把 parsing 後的資料轉換成其他格式
  2. 同時完成驗證與轉換

同時完成驗證與轉換非常實用。

// 1. transform value
const emailToDomain = z
.string()
.email()
.transform((val) => val.split("@")[1]);

emailToDomain.parse("colinhacks@example.com"); // => example.com

// 2. simultaneously validate and transform the value
const numberInString = z.string().transform((val, ctx) => {
const parsed = parseInt(val);
if (isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Not a number",
});

// This is a special symbol you can use to
// return early from the transform function.
// It has type `never` so it does not affect the
// inferred return type.
return z.NEVER;
}
return parsed;
});

.refine

當你需要提供客製化的驗證邏輯,你會需要 refine。如果你需要更進階的客製化,你需要 superRefine。

有很多情況是 typescript 並沒辦法完美的表達你期望表達的 type,舉例來說在 typescript 的世界中想表達整數,還是只能用 number,想表達 valid email 還是只能用 string。

這種時候你會需要 refine 來協助你撰寫驗證邏輯。

const myString = z.string().refine((val) => val.length <= 255, {
message: "String can't be more than 255 characters",
});

注意 refine 函數不應該拋出錯誤。相反,它們應該返回一個假值來表示失敗。具體寫法請參閱文件

Object

預設每個欄位都是必填。

// all properties are required by default
const Dog = z.object({
name: z.string(),
age: z.number(),
});

// extract the inferred type like this
type Dog = z.infer<typeof Dog>;

// equivalent to:
type Dog = {
name: string;
age: number;
};

z.infer 是個很棒的工具,能夠讓你抽取出 output type。

By default Zod object schemas strip out unrecognized keys during parsing.

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

person.parse({
name: "bob dylan",
extraKey: 61,
});
// => { name: "bob dylan" }
// extraKey has been stripped

其餘還有 .and, .or 的工具,協助做出交集與聯集型別。

optional, nullable and nullish

const optionalString = z.string().optional(); // string | undefined

// equivalent to
z.optional(z.string());
const nullableString = z.string().nullable(); // string | null

// equivalent to
z.nullable(z.string());
const nullishString = z.string().nullish(); // string | null | undefined

// equivalent to
z.string().nullable().optional();

Enum

可以分兩個情況況討論

  1. 透過 Zod 定義 enum
  2. 已經有現成的 enum 定義

透過 Zod 定義 enum

const FishEnum = z.enum(["Salmon", "Tuna", "Trout"]);
type FishEnum = z.infer<typeof FishEnum>;

已經有現成的 enum 定義

enum 本身的麻煩點是,要搞清楚你是 string enum 還是 numeric enum

numeric enum

enum Fruits {
Apple,
Banana,
}

const FruitEnum = z.nativeEnum(Fruits);
type FruitEnum = z.infer<typeof FruitEnum>; // Fruits

FruitEnum.parse(Fruits.Apple); // passes
FruitEnum.parse(Fruits.Banana); // passes
FruitEnum.parse(0); // passes
FruitEnum.parse(1); // passes
FruitEnum.parse(3); // fails

mix enum (string + numeric)

enum Fruits {
Apple = "apple",
Banana = "banana",
Cantaloupe, // you can mix numerical and string enums
}

const FruitEnum = z.nativeEnum(Fruits);
type FruitEnum = z.infer<typeof FruitEnum>; // Fruits

FruitEnum.parse(Fruits.Apple); // passes
FruitEnum.parse(Fruits.Cantaloupe); // passes
FruitEnum.parse("apple"); // passes
FruitEnum.parse("banana"); // passes
FruitEnum.parse(0); // passes
FruitEnum.parse("Cantaloupe"); // fails