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大挑戰

  1. 型別與校驗割裂:TypeScript的interface和執行時校驗邏輯需要維護兩份,極易不一致
  2. 表單驗證複雜:巢狀物件、條件校驗、跨欄位聯動,手寫校驗邏輯難以維護
  3. API邊界防護:前端信任後端、後端信任前端,任何一方的型別假設都可能被打破
  4. 資料轉換需求:字串轉數字、日期解析、預設值填充,校驗和轉換混在一起
  5. 錯誤資訊本地化: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校驗才能進入業務邏輯。


線上工具推薦

本站提供瀏覽器本地工具,免註冊即可試用 →

#TypeScript#Zod#Schema验证#运行时校验#表单验证#2026#类型安全