TypeScript Zod驗證:從執行時校驗到型別安全的5個企業級實戰模式
前端工程
你的TypeScript型別在執行時就是個謊言
你寫了完美的interface,TypeScript編譯零錯誤,線上卻炸了——因為API回傳了null而不是string,表單提交了空字串繞過了前端校驗,第三方SDK的型別宣告和實際行為完全不一致。TypeScript的型別只在編譯期存在,執行時沒有任何保障。2026年,Zod已經成為TypeScript生態中執行時校驗的事實標準——它讓你「定義一次schema,同時獲得執行時校驗和編譯期型別推導」。
本文將從Zod核心概念出發,帶你完成schema定義→表單驗證→API校驗→schema轉換→錯誤處理的5個企業級實戰模式,從開發到生產,一步不落。
Zod核心概念
| 概念 | 說明 |
|---|---|
| Schema | 資料結構的宣告式描述,同時提供校驗和型別推導 |
| z.object() | 物件schema,對應TypeScript的interface/type |
| z.infer<> | 從schema自動推導TypeScript型別,消除重複定義 |
| .transform() | schema轉換管道,校驗後對資料做變換 |
| .refine() | 自訂校驗邏輯,支援非同步 |
| .pipe() | schema管道組合,將多個schema串聯 |
| ZodError | 校驗錯誤物件,包含詳細的錯誤路徑和資訊 |
| .safeParse() | 安全解析,不拋異常,回傳Success/Failure結果 |
| .partial() | 將所有欄位變為可選,適合更新操作 |
| .merge() | 合併兩個schema,類似TypeScript的交叉型別 |
校驗流程
資料流入 → schema.parse(data) → 校驗通過 → 回傳型別安全的資料
↓ 校驗失敗
拋出ZodError / safeParse回傳失敗結果
型別推導流程:
1. 定義schema: const UserSchema = z.object({...})
2. 推導型別: type User = z.infer<typeof UserSchema>
3. 執行時校驗: UserSchema.parse(apiResponse)
4. 編譯期型別: parse回傳值自動獲得User型別
問題分析:執行時校驗的5大挑戰
- 型別與校驗割裂:TypeScript的interface和執行時校驗邏輯需要維護兩份,極易不一致
- 表單驗證複雜:巢狀物件、條件校驗、跨欄位聯動,手寫校驗邏輯難以維護
- API邊界防護:前端信任後端、後端信任前端,任何一方的型別假設都可能被打破
- 資料轉換需求:字串轉數字、日期解析、預設值填充,校驗和轉換混在一起
- 錯誤資訊本地化:Zod預設英文錯誤資訊,企業應用需要中文/多語言支援
分步實操:5個企業級實戰模式
模式1:基礎Schema定義與型別推導
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2, '姓名至少2個字元').max(50, '姓名最多50個字元'),
email: z.string().email('信箱格式不正確'),
age: z.number().int().min(0).max(150),
role: z.enum(['admin', 'editor', 'viewer']),
avatar: z.string().url().optional(),
createdAt: z.date(),
tags: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).default({}),
});
type User = z.infer<typeof UserSchema>;
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
type CreateUserInput = z.infer<typeof CreateUserSchema>;
const UpdateUserSchema = UserSchema.partial().omit({ id: true, createdAt: true });
type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
function createUser(input: unknown): User {
return UserSchema.parse(input);
}
function safeCreateUser(input: unknown) {
return UserSchema.safeParse(input);
}
const result = safeCreateUser({
id: '550e8400-e29b-41d4-a716-446655440000',
name: '張三',
email: 'zhangsan@example.com',
age: 28,
role: 'admin',
createdAt: new Date(),
});
if (result.success) {
console.log(result.data.name);
} else {
console.log(result.error.issues);
}
模式2:表單驗證——巢狀物件與跨欄位校驗
import { z } from 'zod';
const AddressSchema = z.object({
province: z.string().min(1, '請選擇省份'),
city: z.string().min(1, '請選擇城市'),
district: z.string().min(1, '請選擇區縣'),
detail: z.string().min(5, '詳細地址至少5個字元').max(200),
postalCode: z.string().regex(/^\d{6}$/, '郵遞區號格式不正確'),
});
const RegistrationFormSchema = z.object({
username: z.string()
.min(3, '使用者名稱至少3個字元')
.max(20, '使用者名稱最多20個字元')
.regex(/^[a-zA-Z0-9_]+$/, '使用者名稱只能包含字母、數字和底線'),
password: z.string()
.min(8, '密碼至少8個字元')
.regex(/[A-Z]/, '密碼必須包含大寫字母')
.regex(/[a-z]/, '密碼必須包含小寫字母')
.regex(/[0-9]/, '密碼必須包含數字')
.regex(/[^A-Za-z0-9]/, '密碼必須包含特殊字元'),
confirmPassword: z.string(),
email: z.string().email('信箱格式不正確'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手機號格式不正確'),
address: AddressSchema,
agreeTerms: z.literal(true, { errorMap: () => ({ message: '必須同意使用者協議' }) }),
}).refine(data => data.password === data.confirmPassword, {
message: '兩次密碼輸入不一致',
path: ['confirmPassword'],
}).refine(data => !data.password.includes(data.username), {
message: '密碼不能包含使用者名稱',
path: ['password'],
});
type RegistrationForm = z.infer<typeof RegistrationFormSchema>;
function validateRegistrationForm(formData: unknown) {
return RegistrationFormSchema.safeParse(formData);
}
const formResult = validateRegistrationForm({
username: 'zhangsan',
password: 'MyP@ss123',
confirmPassword: 'MyP@ss123',
email: 'zhangsan@example.com',
phone: '13800138000',
address: {
province: '北京市',
city: '北京市',
district: '海淀區',
detail: '中關村大街1號',
postalCode: '100080',
},
agreeTerms: true,
});
if (!formResult.success) {
formResult.error.issues.forEach(issue => {
console.log(`${issue.path.join('.')}: ${issue.message}`);
});
}
模式3:API請求/回應校驗
import { z } from 'zod';
import type { Request, Response, NextFunction } from 'express';
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
const ArticleQuerySchema = PaginationSchema.extend({
category: z.string().optional(),
tag: z.string().optional(),
keyword: z.string().optional(),
status: z.enum(['draft', 'published', 'archived']).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
}).refine(data => {
if (data.startDate && data.endDate) {
return data.startDate <= data.endDate;
}
return true;
}, {
message: '開始日期不能晚於結束日期',
path: ['endDate'],
});
const ArticleResponseSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
category: z.string(),
tags: z.array(z.string()),
status: z.enum(['draft', 'published', 'archived']),
author: z.object({
id: z.string(),
name: z.string(),
avatar: z.string().optional(),
}),
publishedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const ArticleListResponseSchema = z.object({
items: z.array(ArticleResponseSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
});
type ArticleQuery = z.infer<typeof ArticleQuerySchema>;
type ArticleListResponse = z.infer<typeof ArticleListResponseSchema>;
function validateRequest<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
res.status(400).json({
error: 'VALIDATION_ERROR',
details: result.error.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message,
})),
});
return;
}
req.query = result.data as any;
next();
};
}
function validateResponse<T>(schema: z.ZodSchema<T>, data: unknown): T {
const result = schema.safeParse(data);
if (!result.success) {
console.error('Response validation failed:', result.error.issues);
throw new Error('Internal server error: response validation failed');
}
return result.data;
}
模式4:Schema轉換與管道
import { z } from 'zod';
const StringToNumber = z.string().transform((val, ctx) => {
const parsed = Number(val);
if (isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `無法將 "${val}" 轉換為數字`,
});
return z.NEVER;
}
return parsed;
});
const DateStringSchema = z.string().transform((val, ctx) => {
const date = new Date(val);
if (isNaN(date.getTime())) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `無效的日期字串: ${val}`,
});
return z.NEVER;
}
return date;
});
const CSVRowSchema = z.object({
name: z.string().trim().min(1),
age: StringToNumber.pipe(z.number().int().min(0).max(150)),
email: z.string().email().toLowerCase(),
registeredAt: DateStringSchema.pipe(z.date()),
isActive: z.string().transform(val => val === 'true' || val === '1').pipe(z.boolean()),
score: StringToNumber.pipe(z.number().min(0).max(100)),
tags: z.string().transform(val => val.split(',').map(s => s.trim()).filter(Boolean)).pipe(z.array(z.string())),
});
type CSVRow = z.infer<typeof CSVRowSchema>;
function parseCSVRow(row: Record<string, string>): CSVRow {
return CSVRowSchema.parse(row);
}
const csvData = {
name: ' 張三 ',
age: '28',
email: 'ZHANGSAN@EXAMPLE.COM',
registeredAt: '2024-01-15T10:30:00Z',
isActive: 'true',
score: '95',
tags: 'TypeScript,React,Node.js',
};
const parsed = parseCSVRow(csvData);
模式5:錯誤處理與本地化
import { z } from 'zod';
import { zodI18nMap } from 'zod-i18n-map';
import i18next from 'i18next';
i18next.init({
lng: 'zh-TW',
resources: {
'zh-TW': {
zod: {
errors: {
invalid_type: ({ expected }) => `型別錯誤,期望 ${expected}`,
too_small: ({ minimum, type }) => {
if (type === 'string') return `至少需要 ${minimum} 個字元`;
if (type === 'number') return `值不能小於 ${minimum}`;
return `值太小`;
},
too_big: ({ maximum, type }) => {
if (type === 'string') return `最多 ${maximum} 個字元`;
if (type === 'number') return `值不能大於 ${maximum}`;
return `值太大`;
},
invalid_string: ({ validation }) => {
const messages: Record<string, string> = {
email: '信箱格式不正確',
url: 'URL格式不正確',
uuid: 'UUID格式不正確',
regex: '格式不正確',
};
return messages[validation] || '字串格式不正確';
},
invalid_enum: ({ options }) => `必須是以下值之一: ${options.join(', ')}`,
required: '此欄位為必填項',
},
},
},
},
});
z.setErrorMap(zodI18nMap);
interface FieldError {
field: string;
message: string;
code: string;
}
function formatZodErrors(error: z.ZodError): FieldError[] {
return error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}));
}
function groupErrorsByField(error: z.ZodError): Record<string, string[]> {
return error.issues.reduce<Record<string, string[]>>((acc, issue) => {
const field = issue.path.join('.');
if (!acc[field]) acc[field] = [];
acc[field].push(issue.message);
return acc;
}, {});
}
const ProductSchema = z.object({
name: z.string().min(2).max(100),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'food']),
description: z.string().min(10),
});
const productResult = ProductSchema.safeParse({
name: 'A',
price: -10,
category: 'invalid',
description: 'short',
});
if (!productResult.success) {
console.log(formatZodErrors(productResult.error));
console.log(groupErrorsByField(productResult.error));
}
避坑指南
坑1:z.coerce靜默轉換導致資料遺失
// ❌ 錯誤:z.coerce會靜默將無效值轉為NaN或0
const schema = z.coerce.number();
schema.parse('abc'); // NaN,不報錯!
schema.parse(null); // 0,不報錯!
// ✅ 正確:先校驗原始型別,再轉換
const safeNumberSchema = z.string().transform((val, ctx) => {
const num = Number(val);
if (isNaN(num)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '無法轉換為數字',
});
return z.NEVER;
}
return num;
}).pipe(z.number());
坑2:.optional()與.undefined()混淆
// ❌ 錯誤:.optional()允許undefined但不允許缺少key
const schema = z.object({
name: z.string().optional(),
});
schema.parse({ name: undefined }); // OK
schema.parse({}); // 也OK,但容易混淆
// ✅ 正確:明確區分可選和可缺少
const betterSchema = z.object({
name: z.string().optional(),
nickname: z.string().nullable().optional(),
});
坑3:refine中忘記回傳boolean
// ❌ 錯誤:refine回呼沒有回傳值
.refine(data => {
data.password === data.confirmPassword;
})
// ✅ 正確:refine必須回傳boolean
.refine(data => data.password === data.confirmPassword, {
message: '兩次密碼輸入不一致',
path: ['confirmPassword'],
})
坑4:在schema中使用Date物件導致序列化問題
// ❌ 錯誤:z.date()要求輸入是Date物件,JSON解析後是字串
const schema = z.object({
createdAt: z.date(),
});
schema.parse(JSON.parse('{"createdAt":"2024-01-01"}')); // 報錯!
// ✅ 正確:用z.string().datetime()接收字串,再轉換
const schema = z.object({
createdAt: z.string().datetime().transform(s => new Date(s)),
});
// 或者用z.coerce.date()自動轉換
const coerceSchema = z.object({
createdAt: z.coerce.date(),
});
坑5:schema定義循環引用
// ❌ 錯誤:直接循環引用會導致無限遞迴
const TreeNodeSchema = z.object({
value: z.string(),
children: z.array(TreeNodeSchema), // ReferenceError!
});
// ✅ 正確:使用z.lazy延遲求值
const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
z.object({
value: z.string(),
children: z.array(TreeNodeSchema).default([]),
})
);
interface TreeNode {
value: string;
children: TreeNode[];
}
報錯排查
| 序號 | 報錯資訊 | 原因 | 解決方法 |
|---|---|---|---|
| 1 | Expected string, received number |
型別不匹配 | 使用z.coerce或.transform轉換型別 |
| 2 | Required at path "field" |
必填欄位缺少 | 新增.optional()或提供預設值 |
| 3 | String must contain at least N character(s) |
字串長度不足 | 檢查.min()約束或輸入資料 |
| 4 | Invalid enum value |
列舉值不匹配 | 檢查z.enum()定義,確認輸入值在列舉清單中 |
| 5 | Invalid datetime |
日期格式不正確 | 使用ISO 8601格式或z.coerce.date() |
| 6 | Custom message: ... |
refine校驗失敗 | 檢查refine/transform中的自訂邏輯 |
| 7 | Too many items in array |
陣列長度超限 | 調整.max()約束或檢查輸入資料 |
| 8 | Invalid URL |
URL格式不正確 | 確保包含協定前綴(http/https) |
| 9 | Invalid email |
信箱格式不正確 | 檢查@符號和域名部分 |
| 10 | Pipeline transform error |
pipe中轉換失敗 | 檢查管道中每個階段的輸入輸出型別匹配 |
進階最佳化
1. Schema複用與組合模式
import { z } from 'zod';
const BaseEntitySchema = z.object({
id: z.string().uuid(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const NamedEntitySchema = BaseEntitySchema.extend({
name: z.string().min(1),
description: z.string().optional(),
});
const ProductSchema = NamedEntitySchema.extend({
price: z.number().positive(),
category: z.string(),
sku: z.string().regex(/^[A-Z]{2}\d{6}$/),
variants: z.array(z.object({
name: z.string(),
price: z.number().positive(),
stock: z.number().int().nonnegative(),
})).default([]),
});
const OrderSchema = NamedEntitySchema.extend({
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})).min(1, '訂單至少包含一個商品'),
totalAmount: z.number().nonnegative(),
status: z.enum(['pending', 'paid', 'shipped', 'delivered', 'cancelled']),
});
type Product = z.infer<typeof ProductSchema>;
type Order = z.infer<typeof OrderSchema>;
2. 條件Schema——基於欄位值動態校驗
import { z } from 'zod';
const PaymentSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('credit_card'),
cardNumber: z.string().regex(/^\d{16}$/, '卡號必須是16位數字'),
expiryMonth: z.number().int().min(1).max(12),
expiryYear: z.number().int().min(2026).max(2036),
cvv: z.string().regex(/^\d{3,4}$/, 'CVV格式不正確'),
}),
z.object({
type: z.literal('bank_transfer'),
bankCode: z.string().min(1, '請選擇銀行'),
accountNumber: z.string().regex(/^\d{10,20}$/, '帳號格式不正確'),
accountName: z.string().min(1, '請輸入帳戶名'),
}),
z.object({
type: z.literal('alipay'),
alipayAccount: z.string().email('請輸入支付寶綁定的信箱'),
}),
]);
type Payment = z.infer<typeof PaymentSchema>;
function validatePayment(data: unknown) {
return PaymentSchema.safeParse(data);
}
3. Schema版本遷移
import { z } from 'zod';
const UserV1Schema = z.object({
name: z.string(),
email: z.string().email(),
});
const UserV2Schema = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
phone: z.string().optional(),
});
const UserV3Schema = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
phone: z.string().optional(),
avatar: z.string().url().optional(),
preferences: z.object({
language: z.enum(['zh-CN', 'en', 'ja']).default('zh-CN'),
theme: z.enum(['light', 'dark', 'system']).default('system'),
}).default({}),
});
function migrateUserV1ToV3(v1Data: unknown) {
const v1 = UserV1Schema.parse(v1Data);
const nameParts = v1.name.split(' ');
return UserV3Schema.parse({
firstName: nameParts[0] || '',
lastName: nameParts.slice(1).join(' ') || '',
email: v1.email,
});
}
function migrateUserV2ToV3(v2Data: unknown) {
const v2 = UserV2Schema.parse(v2Data);
return UserV3Schema.parse({
...v2,
});
}
對比分析
| 維度 | Zod | Yup | Joi | class-validator | io-ts |
|---|---|---|---|---|---|
| TypeScript型別推導 | ✅ 自動 | ⚠️ 需手動 | ❌ 無 | ⚠️ 需裝飾器 | ✅ 自動 |
| Bundle大小 | ~13KB | ~18KB | ~180KB | ~35KB | ~8KB |
| 非同步校驗 | ✅ refine | ✅ validate | ✅ validateAsync | ❌ | ✅ |
| Schema轉換 | ✅ transform | ❌ | ❌ | ❌ | ✅ |
| 錯誤資訊本地化 | ✅ zod-i18n-map | ⚠️ 手動 | ⚠️ 手動 | ⚠️ 手動 | ❌ |
| 不可變資料 | ✅ | ✅ | ✅ | ❌ | ✅ |
| 學習曲線 | 低 | 低 | 中 | 中 | 高 |
| 生態整合 | React/Next/Express | Formik | Express | NestJS | fp-ts |
總結:Zod不是「又一個校驗庫」,而是「TypeScript執行時型別系統的補完者」。它的核心價值在於
z.infer<>——定義一次schema,同時獲得執行時校驗和編譯期型別,徹底消除interface與校驗邏輯的割裂。2026年的企業級實踐路徑:先用Zod替代手寫校驗→再配合React Hook Form實現表單驗證→最後用Zod中介軟體守衛API邊界。關鍵是要建立「不信任任何外部資料」的意識——所有從API、表單、檔案讀取的資料,都必須經過schema校驗才能進入業務邏輯。
線上工具推薦
- JSON格式化:/zh-TW/json/format
- Base64編解碼:/zh-TW/encode/base64
- Hash計算:/zh-TW/encode/hash
- JWT解碼:/zh-TW/encode/jwt-decode
本站提供瀏覽器本地工具,免註冊即可試用 →
#TypeScript#Zod#Schema验证#运行时校验#表单验证#2026#类型安全