跳至主要內容
Skip to content

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 zod

2.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 推導類型
  • 建立通用驗證中介軟體

進階挑戰

  1. 建立一個完整的 API 驗證層
  2. 實作一個表單驗證 hook(Vue/React)
  3. 使用 Zod 驗證環境變數

延伸閱讀與資源