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 變成日期物件
學習要點
- 如何把 raw input 轉換成符合驗證條件的 typed output
- raw input 的 typing 是怎麼決定的?
- typed output 的 typing 是怎麼決定的?
- 怎麼定義驗證條件?
- 怎麼進行轉換?
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 的處理流程是
- (optional) preprocess
- parse & validate
- (optional) transform
.preprocess
是舊版常出現的方法,但新版通常可以被 z.coerce 取代掉
const castToString = z.preprocess((val) => String(val), z.string());
// is similar to
z.coerce.string();
.transform 在 zod 內有兩個用途
- 把 parsing 後的資料轉換成其他格式
- 同時完成驗證與轉換
同時完成驗證與轉換非常實用。
// 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
可以分兩個情況況討論
- 透過 Zod 定義 enum
- 已經有現成的 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