本文 AI 產出,尚未審核

ExpressJS (TypeScript) – Validation 請求驗證

使用 Zod / Yup 驗證 Schema


簡介

在 Web API 開發中,請求資料的驗證是不可或缺的安全與穩定基礎。若不對前端送來的參數進行嚴格檢查,容易導致資料庫錯誤、業務邏輯崩潰,甚至成為駭客攻擊的入口。
Express 搭配 TypeScript 已經提供了型別檢查的能力,但型別只在編譯期有效,執行時仍須自行驗證。這時候使用 ZodYup 這類宣告式驗證函式庫,就能在路由層快速、統一地定義 schema,同時保留完整的 TypeScript 型別推斷,讓程式碼既安全又易讀。

本文將從概念說明、實作範例、常見陷阱與最佳實踐,一步步帶你在 Express + TypeScript 專案中導入 Zod/Yup,完成完整的請求驗證流程。


核心概念

1. 為什麼選擇 Zod / Yup

特性 Zod Yup
型別推斷 完全支援 TypeScript,編譯期即得到正確型別 需要 asInferType 才能取得型別
API 風格 函式式、鏈式呼叫、純函式 類似 Joi 的宣告式
效能 輕量、編譯時即生成驗證函式 相對較重,適合較複雜的條件驗證
社群與文件 官方文件完整,持續更新 仍在活躍維護,文件較分散

兩者皆支援 同步與非同步 的驗證流程,且可以與 Express 中間件輕鬆結合。選擇哪一個多半取決於團隊習慣與專案需求;以下範例同時示範兩者的寫法,讓你可以自行比較。


2. 基本使用流程

  1. 定義 Schema:描述請求 body、query、params 的結構與限制。
  2. 建立驗證中間件:將 Schema 包裝成 Express 中間件,於路由觸發前執行。
  3. 取得已驗證的資料:驗證成功時,將淨化後的資料掛到 req 上,供後續處理使用。
  4. 錯誤處理:統一回傳錯誤訊息與 HTTP 狀態碼。

3. Zod 範例

3.1 安裝

npm i zod
npm i -D @types/express

3.2 定義 Schema

// src/schemas/userSchema.ts
import { z } from 'zod';

// 以 Zod 宣告使用者註冊的欄位
export const RegisterSchema = z.object({
  username: z.string()
    .min(3, { message: '帳號長度至少 3 個字元' })
    .max(20, { message: '帳號長度不可超過 20 個字元' })
    .regex(/^[a-zA-Z0-9_]+$/, { message: '只能使用英數與底線' }),

  email: z.string()
    .email({ message: '請輸入有效的 Email' }),

  password: z.string()
    .min(8, { message: '密碼長度至少 8 個字元' })
    .regex(/[A-Z]/, { message: '必須包含大寫字母' })
    .regex(/[0-9]/, { message: '必須包含數字' }),

  // optional field with default value
  role: z.enum(['user', 'admin']).default('user')
});

// 讓 TypeScript 自動推斷型別
export type RegisterInput = z.infer<typeof RegisterSchema>;

3.3 建立驗證中間件

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';

/**
 * 產生一個可重複使用的 Zod 驗證中間件
 * @param schema 欲驗證的 Zod schema
 * @param property 要驗證的來源 (body / query / params)
 */
export const validate =
  (schema: ZodSchema<any>, property: 'body' | 'query' | 'params' = 'body') =>
  (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[property]);

    if (!result.success) {
      // 取得所有錯誤訊息
      const errors = result.error.format();
      return res.status(400).json({
        message: '參數驗證失敗',
        errors,
      });
    }

    // 將淨化後的資料掛回 req,供後續使用
    req[property] = result.data;
    next();
  };

3.4 套用於路由

// src/routes/auth.ts
import { Router } from 'express';
import { validate } from '../middleware/validate';
import { RegisterSchema, RegisterInput } from '../schemas/userSchema';

const router = Router();

/**
 * POST /auth/register
 * 使用 Zod 進行請求驗證
 */
router.post(
  '/register',
  validate(RegisterSchema, 'body'), // <-- 中間件
  (req, res) => {
    // 此時 TypeScript 已知 req.body 為 RegisterInput
    const data: RegisterInput = req.body;
    // TODO: 實作註冊邏輯 (hash password、寫入 DB 等)
    res.status(201).json({ message: '註冊成功', user: data });
  }
);

export default router;

重點validate 中間件不僅會回傳錯誤訊息,還會把 淨化後的資料(已去除多餘屬性、轉型)寫回 req,讓後續的控制器可以直接使用正確型別。


4. Yup 範例

4.1 安裝

npm i yup
npm i -D @types/express

4.2 定義 Schema

// src/schemas/productSchema.ts
import * as yup from 'yup';

export const CreateProductSchema = yup.object({
  name: yup.string()
    .required('商品名稱為必填')
    .min(2, '名稱最少 2 個字元'),

  price: yup.number()
    .required('價格為必填')
    .positive('價格必須大於 0'),

  tags: yup.array()
    .of(yup.string().max(15))
    .default([]),

  // 非同步驗證:檢查 SKU 是否已存在(假設有一個 DB 查詢函式)
  sku: yup.string()
    .required()
    .test('unique-sku', 'SKU 已被使用', async (value) => {
      // 假設有 async function checkSkuExists(sku: string): Promise<boolean>
      const exists = await checkSkuExists(value!);
      return !exists;
    })
});

4.3 建立驗證中間件(支援 async)

// src/middleware/validateYup.ts
import { Request, Response, NextFunction } from 'express';
import { AnySchema } from 'yup';

export const validateYup =
  (schema: AnySchema, property: 'body' | 'query' | 'params' = 'body') =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const validated = await schema.validate(req[property], {
        abortEarly: false, // 收集所有錯誤
        stripUnknown: true, // 移除未在 schema 中的屬性
      });
      req[property] = validated;
      next();
    } catch (err: any) {
      // Yup 會拋出 ValidationError
      return res.status(400).json({
        message: '參數驗證失敗',
        errors: err.errors, // 陣列形式的錯誤訊息
      });
    }
  };

4.4 套用於路由

// src/routes/product.ts
import { Router } from 'express';
import { validateYup } from '../middleware/validateYup';
import { CreateProductSchema } from '../schemas/productSchema';

const router = Router();

router.post(
  '/',
  validateYup(CreateProductSchema, 'body'), // <-- 中間件
  (req, res) => {
    const product = req.body; // 已經是 Yup 清理過的資料
    // TODO: 寫入資料庫
    res.status(201).json({ message: '商品建立成功', product });
  }
);

export default router;

5. 常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記在中間件回傳 next() 中間件未呼叫 next() 會導致請求卡住 確保成功驗證後一定 return next();
混用 any 失去型別安全 直接使用 req.body as any 會失去 Zod/Yup 的好處 使用 z.infer<>as const 取得正確型別
未處理非同步驗證錯誤 Yup 的 test 需要 async/await,忽略會導致未捕獲的例外 在中間件使用 try/catchsafeParseAsync
錯誤訊息過於冗長 直接回傳整個錯誤物件會暴露內部實作細節 只回傳 message 或自訂錯誤格式,避免資訊洩漏
驗證順序錯誤 先執行業務邏輯再驗證,會浪費資源 把驗證中間件放在路由最前面,確保「先驗證、後執行」

最佳實踐

  1. 統一錯誤格式:建立一個全局的錯誤處理器,將 Zod/Yup 的錯誤轉換成 { code, field, message } 的結構。
  2. 使用 TypeScript 型別推斷:盡量避免自行寫 interface,改用 z.inferyup.InferType,保持驗證與型別同步。
  3. 分層驗證:對於 queryparamsbody 分別建立獨立 Schema,避免一次性寫過長的物件。
  4. 設定 stripUnknown: true(Yup)或 removeUnknownKeys: true(Zod):自動剔除多餘欄位,減少惡意資料注入。
  5. 重用 Schema:共用的欄位(如 idpage)可抽成獨立模組,降低維護成本。

6. 實際應用場景

場景 需求 建議方案
使用者註冊 必須檢查 Email 格式、密碼強度、帳號唯一性 Zod + 自訂 async refine 檢查 DB,或 Yup 的 test
分頁查詢 pagelimit 必須是正整數,且 limit 不超過 100 Zod z.coerce.number().int().min(1)z.preprocess 轉型
檔案上傳 + 其他欄位 multipart/form-data 中的文字欄位需要驗證,檔案類型與大小也要檢查 先用 multer 處理檔案,再用 Zod/Yup 驗證 req.body,最後在路由內檢查 req.file
第三方 API Callback 收到的簽名與時間戳必須驗證,防止重放攻擊 使用 Zod z.object({ timestamp: z.string().refine(isRecent) }),結合自訂 refine
多語系表單 前端會送 localetranslations 物件,必須保證每個語系都有必填欄位 Zod z.record(z.object({ title: z.string().min(1) })),或 Yup 的 object().shape({}).noUnknown()

總結

  • 請求驗證是 API 安全與健全的第一道防線,僅靠 TypeScript 型別不足以防止執行時的錯誤。
  • Zod 提供 零成本的型別推斷高效能的同步驗證,非常適合需要大量型別同步的專案。
  • Yup 則在 非同步驗證(如資料庫唯一性檢查)上較為便利,且語法較貼近 Joi,適合已有 Yup 經驗的團隊。
  • 透過 統一的驗證中間件,把所有驗證邏輯抽離到路由之外,保持控制器的乾淨與可測試性。
  • 切記 錯誤訊息要友好且不洩漏內部實作,並在全局錯誤處理器中統一回傳格式,讓前端可以一致地呈現驗證結果。

掌握了 Zod/Yup 的基本用法與最佳實踐後,你就能在 Express + TypeScript 專案裡 快速、可靠地保護每一筆進來的資料,同時享受到 TypeScript 完整型別推斷所帶來的開發效率提升。祝你寫程式順利,API 安全無慮!