API 類型安全:Zod 驗證
TypeScript 的類型只在編譯時期存在,執行時期仍需要驗證。Zod 結合了兩者,實現完整的類型安全。
一、 為什麼需要 Zod?
1.1 TypeScript 的限制
typescript
interface CreateUserBody {
name: string;
email: string;
}
app.post('/users', (req: Request<{}, {}, CreateUserBody>, res) => {
const { name, email } = req.body;
// 編譯時期 TypeScript 認為是 string
// 但執行時期可能是 undefined、number 或任何東西!
});1.2 Zod 的解決方案
typescript
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// 從 schema 推導類型
type CreateUserBody = z.infer<typeof createUserSchema>;
app.post('/users', (req, res) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
// result.data 是已驗證的類型安全資料
const { name, email } = result.data;
});二、 基本使用
2.1 安裝
bash
npm install zod2.2 基本類型
typescript
import { z } from 'zod';
// 原始類型
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
// 驗證
stringSchema.parse('hello'); // 'hello'
stringSchema.parse(123); // 拋出錯誤
// 安全驗證
const result = stringSchema.safeParse(123);
if (!result.success) {
console.log(result.error);
}2.3 物件
typescript
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
});
// 推導類型
type User = z.infer<typeof userSchema>;
// { name: string, email: string, age: number }
// 驗證
const user = userSchema.parse({
name: 'John',
email: 'john@example.com',
age: 30,
});三、 常用驗證器
3.1 字串
typescript
z.string().min(1); // 至少 1 個字元
z.string().max(100); // 最多 100 個字元
z.string().length(10); // 剛好 10 個字元
z.string().email(); // Email 格式
z.string().url(); // URL 格式
z.string().uuid(); // UUID 格式
z.string().regex(/^[a-z]+$/); // 正規表達式
z.string().includes('hello'); // 包含子字串
z.string().startsWith('http'); // 開頭
z.string().endsWith('.com'); // 結尾
z.string().trim(); // 自動去除空白
z.string().toLowerCase(); // 轉小寫3.2 數字
typescript
z.number().int(); // 整數
z.number().positive(); // 正數
z.number().negative(); // 負數
z.number().nonnegative(); // 非負數
z.number().min(0); // 最小值
z.number().max(100); // 最大值
z.number().multipleOf(5); // 倍數
z.number().finite(); // 有限數3.3 陣列
typescript
z.array(z.string()); // string[]
z.array(z.number()).min(1); // 至少 1 個元素
z.array(z.number()).max(10); // 最多 10 個元素
z.array(z.number()).length(3); // 剛好 3 個元素
z.array(z.number()).nonempty(); // 非空陣列
// 元組
z.tuple([z.string(), z.number()]);
// [string, number]四、 進階功能
4.1 可選與預設值
typescript
const schema = z.object({
name: z.string(),
email: z.string().optional(), // string | undefined
age: z.number().nullable(), // number | null
role: z.string().default('user'), // 預設值
status: z.string().nullish(), // string | null | undefined
});4.2 聯合類型
typescript
// 字面量聯合
const statusSchema = z.enum(['pending', 'success', 'error']);
type Status = z.infer<typeof statusSchema>;
// 'pending' | 'success' | 'error'
// 類型聯合
const stringOrNumber = z.union([z.string(), z.number()]);
type StringOrNumber = z.infer<typeof stringOrNumber>;
// string | number
// 簡寫
const stringOrNumber2 = z.string().or(z.number());4.3 區分聯合
typescript
const eventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keydown'), key: z.string() }),
]);
type Event = z.infer<typeof eventSchema>;
// { type: 'click', x: number, y: number } | { type: 'keydown', key: string }4.4 轉換
typescript
// 轉換類型
const stringToNumber = z.string().transform((val) => parseInt(val, 10));
stringToNumber.parse('42'); // 42 (number)
// 鏈式轉換
const trimmedLowerCase = z
.string()
.trim()
.toLowerCase()
.transform((val) => val.replace(/\s+/g, '-'));4.5 精煉(Refine)
typescript
// 自訂驗證邏輯
const passwordSchema = z
.string()
.min(8)
.refine((val) => /[A-Z]/.test(val), {
message: '必須包含大寫字母',
})
.refine((val) => /[0-9]/.test(val), {
message: '必須包含數字',
});
// 跨欄位驗證
const formSchema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: '密碼不一致',
path: ['confirmPassword'],
});五、 Express 整合
5.1 驗證中介軟體
typescript
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
interface ValidatedRequest<T> extends Request {
validated: T;
}
function validate<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
errors: result.error.flatten().fieldErrors,
});
}
(req as ValidatedRequest<T>).validated = result.data;
next();
};
}
// 使用
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post(
'/users',
validate(createUserSchema),
(req: ValidatedRequest<z.infer<typeof createUserSchema>>, res) => {
const { name, email } = req.validated;
// 類型安全!
}
);5.2 完整驗證
typescript
interface ValidationSchemas {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
function validateRequest(schemas: ValidationSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const errors: Record<string, unknown> = {};
if (schemas.body) {
const result = schemas.body.safeParse(req.body);
if (!result.success) {
errors.body = result.error.flatten().fieldErrors;
}
}
if (schemas.query) {
const result = schemas.query.safeParse(req.query);
if (!result.success) {
errors.query = result.error.flatten().fieldErrors;
}
}
if (schemas.params) {
const result = schemas.params.safeParse(req.params);
if (!result.success) {
errors.params = result.error.flatten().fieldErrors;
}
}
if (Object.keys(errors).length > 0) {
return res.status(400).json({ success: false, errors });
}
next();
};
}
// 使用
app.get(
'/users/:id',
validateRequest({
params: z.object({ id: z.string().uuid() }),
query: z.object({ include: z.string().optional() }),
}),
(req, res) => {
// 已驗證
}
);六、 錯誤處理
6.1 格式化錯誤
typescript
const result = schema.safeParse(data);
if (!result.success) {
// flatten() 扁平化錯誤
const flattened = result.error.flatten();
// { formErrors: string[], fieldErrors: { [key]: string[] } }
// format() 格式化錯誤
const formatted = result.error.format();
// 巢狀結構,每個欄位有 _errors 陣列
// issues 原始錯誤
const issues = result.error.issues;
// [{ code, path, message }, ...]
}6.2 自訂錯誤訊息
typescript
const schema = z.object({
name: z
.string({
required_error: '名稱為必填',
invalid_type_error: '名稱必須是字串',
})
.min(1, '名稱不能為空'),
email: z.string().email({ message: '無效的 Email 格式' }),
age: z.number().min(18, { message: '必須年滿 18 歲' }),
});七、 實用範例
7.1 分頁查詢
typescript
const paginationSchema = z.object({
page: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
.default('1'),
limit: z
.string()
.transform(Number)
.pipe(z.number().int().min(1).max(100))
.default('10'),
sort: z.enum(['asc', 'desc']).default('desc'),
orderBy: z.string().optional(),
});
type PaginationQuery = z.infer<typeof paginationSchema>;7.2 使用者註冊
typescript
const registerSchema = z
.object({
username: z
.string()
.min(3, '使用者名稱至少 3 個字元')
.max(20, '使用者名稱最多 20 個字元')
.regex(/^[a-zA-Z0-9_]+$/, '只能包含英文、數字和底線'),
email: z.string().email('無效的 Email 格式'),
password: z
.string()
.min(8, '密碼至少 8 個字元')
.regex(/[A-Z]/, '必須包含大寫字母')
.regex(/[a-z]/, '必須包含小寫字母')
.regex(/[0-9]/, '必須包含數字'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: '密碼不一致',
path: ['confirmPassword'],
});
type RegisterInput = z.infer<typeof registerSchema>;總結
| 功能 | 語法 |
|---|---|
| 基本類型 | z.string(), z.number() |
| 物件 | z.object({}) |
| 陣列 | z.array() |
| 可選 | .optional() |
| 預設值 | .default() |
| 轉換 | .transform() |
| 精煉 | .refine() |
| 推導類型 | z.infer<typeof schema> |
> **最佳實踐**:
- 將 schema 定義在獨立檔案
- 使用
z.infer推導類型 - 建立通用驗證中介軟體
進階挑戰
- 建立一個完整的 API 驗證層
- 實作一個表單驗證 hook(Vue/React)
- 使用 Zod 驗證環境變數