本文 AI 產出,尚未審核

ExpressJS (TypeScript) – Validation 請求驗證

Express + Zod middleware 實作


簡介

在 Web API 開發中,請求驗證是不可或缺的第一道防線。若未對前端傳入的資料做好檢查,就可能導致資料庫錯誤、業務邏輯異常,甚至成為駭客攻擊的入口。傳統上,我們會在路由處理函式內手動檢查每個欄位,程式碼既冗長又容易遺漏。

近年來,Zod 以其 TypeScript‑first、聲明式的 schema 定義方式,成為最受歡迎的驗證函式庫之一。將 Zod 與 Express 結合,寫成 middleware,不僅可讓驗證邏輯與路由分離,還能在開發階段即取得完整的型別推斷,提升開發效率與程式安全性。

本文將一步步帶你建立一套通用的 Express + Zod 中介層,從基本概念到實務應用,讓你在 TypeScript 專案中快速上手請求驗證。


核心概念

1. 為什麼選擇 Zod?

  • TypeScript‑first:Zod 的 schema 本身即是 TypeScript 型別,使用 z.infer<> 可以自動推斷出介面,避免重複定義。
  • 聲明式:以函式鏈的方式描述欄位規則,可讀性極佳。
  • 同步/非同步驗證:支援 parseAsync,方便與資料庫或外部服務的驗證結合。
  • 錯誤訊息客製化:可自行設定錯誤訊息或使用 ZodError.format() 產生結構化回傳。

2. Express Middleware 的運作原理

在 Express 中,middleware 是一個接受 (req, res, next) 三個參數的函式。當請求到達路由之前,middleware 可以:

  1. 讀取或修改 req(例如把驗證後的資料放進 req.body)。
  2. 若驗證失敗,直接回傳錯誤回應,阻止 往下的路由處理。
  3. 驗證成功後呼叫 next(),讓請求繼續往下傳遞。

結合 Zod,middleware 的核心流程為:

req  -->  Zod schema.parse(req.body)  -->  成功 → req.parsed = data → next()
                                            失敗 → res.status(400).json(error)

3. 建立通用的驗證中介層

為了讓每條路由都能簡潔地掛載驗證,我們會寫一個 高階函式 validate(schema, property),返回符合 Express 型別的 middleware。

  • schema:Zod 定義的驗證規則。
  • property:要驗證的來源(bodyqueryparams),預設為 body
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { ZodSchema, ZodError } from 'zod';

/**
 * 產生驗證 middleware
 * @param schema Zod schema
 * @param property 要驗證的屬性 (body | query | params)
 */
export function validate<T>(
  schema: ZodSchema<T>,
  property: 'body' | 'query' | 'params' = 'body'
): RequestHandler {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      // 取得要驗證的原始資料
      const data = req[property];
      // 同步驗證,若失敗會拋出 ZodError
      const parsed = schema.parse(data);
      // 把驗證後的資料掛在 req 上,方便後續使用
      (req as any)[property] = parsed;
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        // 產生結構化錯誤訊息
        const formatted = err.format();
        res.status(400).json({
          message: 'Invalid request data',
          errors: formatted,
        });
      } else {
        // 其他未知錯誤直接交給全域錯誤處理器
        next(err);
      }
    }
  };
}

重點(req as any)[property] = parsed; 讓 TypeScript 仍保留型別資訊,後續路由可以直接使用 req.body(已經是安全的型別)而不需要再做斷言。

4. 範例:基本 CRUD API 的驗證

以下示範三個常見的 API:建立使用者取得使用者列表(含 query 篩選)以及 更新使用者(含 URL 參數)。

4.1 建立使用者(POST /users)

import express from 'express';
import { z } from 'zod';
import { validate } from './middleware/validate';

const router = express.Router();

// Zod schema 定義
const CreateUserSchema = z.object({
  name: z.string().min(2, { message: '名稱至少 2 個字元' }),
  email: z.string().email({ message: 'Email 格式不正確' }),
  password: z.string().min(6, { message: '密碼最少 6 個字元' }),
  age: z.number().int().positive().optional(),
});

router.post(
  '/users',
  // 掛載驗證 middleware
  validate(CreateUserSchema, 'body'),
  async (req, res) => {
    // 此時 req.body 已是安全的型別
    const newUser = await UserModel.create(req.body);
    res.status(201).json(newUser);
  }
);

4.2 取得使用者列表(GET /users?age=20&sort=desc)

// query 參數的 schema
const GetUsersQuerySchema = z.object({
  age: z.coerce.number().int().positive().optional(),
  sort: z.enum(['asc', 'desc']).default('asc'),
});

router.get(
  '/users',
  validate(GetUsersQuerySchema, 'query'),
  async (req, res) => {
    const { age, sort } = req.query; // 已經是正確型別
    const users = await UserModel.find({ ...(age && { age }) }).sort({ name: sort });
    res.json(users);
  }
);

技巧:使用 z.coerce.number() 可自動把字串型別的 query 轉為數字,減少手動 Number() 的步驟。

4.3 更新使用者(PATCH /users/:id)

// URL 參數 schema
const UserIdParamSchema = z.object({
  id: z.string().uuid({ message: 'id 必須是有效的 UUID' }),
});

// Body 部分的 schema(允許部分欄位更新)
const UpdateUserSchema = z.object({
  name: z.string().min(2).optional(),
  email: z.string().email().optional(),
  age: z.number().int().positive().optional(),
}).refine(data => Object.keys(data).length > 0, {
  message: '至少需要提供一個欄位進行更新',
});

router.patch(
  '/users/:id',
  // 先驗證 URL 參數,再驗證 body
  validate(UserIdParamSchema, 'params'),
  validate(UpdateUserSchema, 'body'),
  async (req, res) => {
    const { id } = req.params;
    const updated = await UserModel.findByIdAndUpdate(id, req.body, { new: true });
    if (!updated) return res.status(404).json({ message: '使用者不存在' });
    res.json(updated);
  }
);

5. 非同步驗證:結合資料庫唯一性檢查

有時候僅靠 Zod 的同步驗證不足,例如 email 必須唯一。我們可以利用 parseAsync 搭配自訂的 refine 來完成非同步檢查。

const RegisterSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
}).refine(
  async data => {
    const exists = await UserModel.exists({ email: data.email });
    return !exists;
  },
  {
    message: '此 Email 已被註冊',
    path: ['email'], // 錯誤會指向 email 欄位
  }
);

// 中介層改寫為 async 版
export function validateAsync<T>(
  schema: ZodSchema<T>,
  property: 'body' | 'query' | 'params' = 'body'
): RequestHandler {
  return async (req, res, next) => {
    try {
      const parsed = await schema.parseAsync(req[property]);
      (req as any)[property] = parsed;
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        res.status(400).json({ message: 'Invalid data', errors: err.format() });
      } else {
        next(err);
      }
    }
  };
}

// 路由使用方式
router.post('/register', validateAsync(RegisterSchema, 'body'), async (req, res) => {
  const user = await UserModel.create(req.body);
  res.status(201).json(user);
});

常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記在 middleware 中呼叫 next() 請求會卡在驗證階段,永遠不會回傳。 確認 next() 放在 try 區塊最後,或在錯誤分支中直接回應。
直接使用 any 逃避型別 失去 TypeScript 的安全保障。 透過 z.infer<> 取得型別,或在 middleware 中把 req.body 重新賦值為 parsed 結果。
驗證錯誤回傳不一致 前端難以統一處理錯誤訊息。 統一錯誤格式(如 { message, errors }),並在全域錯誤處理器中統一封裝。
同步驗證無法處理唯一性、遠端檢查 只用 parse 會忽略非同步需求。 使用 parseAsync 並在 schema 中加入 refinesuperRefine
驗證過程中拋出非 ZodError 會直接走到全域錯誤處理,可能暴露內部細節。 在 catch 中先判斷 instanceof ZodError,其餘錯誤交給 next(err)

最佳實踐

  1. 保持 schema 純粹:盡量只描述資料結構與基本驗證,複雜業務邏輯放在 service 層或 refine 中。
  2. 集中管理 middleware:將 validatevalidateAsync 放在 src/middleware/validation.ts,所有路由統一引用。
  3. 錯誤訊息國際化:如果專案需要多語系,ZodError.format() 產出的錯誤結構很適合再經過翻譯層處理。
  4. 測試覆蓋:對每個 schema 撰寫單元測試,確保欄位限制、預設值、refine 條件皆正確。

實際應用場景

1. 公開 API 平台

在提供第三方開發者使用的 REST API 時,每一筆請求都必須嚴格驗證,防止惡意資料破壞服務。使用 Zod 中介層,可在路由層之前完成所有欄位、型別與業務規則的檢查,並返回統一的錯誤格式,減少文件說明的負擔。

2. 微服務間的資料交換

微服務之間常以 JSON 互傳訊息。若每個服務都使用相同的 Zod schema 定義(可抽成共用套件),即使服務語言不同,只要在 TypeScript 端使用 zod-to-json-schema 生成 JSON Schema,雙方即可保證 契約一致性

3. 表單驅動的前端應用

React、Vue 等前端框架常需要在送出表單前先在前端驗證。將相同的 Zod schema 同時用於前端(zod 本身支援瀏覽器)與後端,能 一次撰寫、雙端共享,大幅降低驗證不一致的風險。

4. 需要非同步驗證的註冊流程

如前文示範的 email 唯一性邀請碼是否有效,都需要查詢資料庫或 Redis。使用 parseAsync 搭配 refine,可以把這些非同步檢查寫進 schema,保持驗證流程的單一入口


總結

  • Zod 為 TypeScript 提供了聲明式、型別安全的驗證解決方案,與 Express 中介層結合後,可把請求驗證抽象成可重用的函式。
  • 透過 validate / validateAsync 兩個高階函式,我們能在路由之前完成同步或非同步驗證,並把已驗證的資料安全地掛在 req 上供後續使用。
  • 注意常見陷阱(忘記 next()、錯誤回傳不一致、濫用 any)以及遵循最佳實踐(統一錯誤格式、集中管理 schema、完整測試),即可打造 健全、易維護 的 API。
  • 在實務上,無論是公開 API、微服務、前端表單或需要非同步檢查的註冊流程,Zod + Express 的組合都能提供一致、可預測的驗證體驗。

把本文的範例程式碼直接套用到你的專案中,讓每一次的 API 呼叫都在進入業務邏輯前得到嚴格的保護,從此 錯誤減少、開發更快、維護更安心!祝開發順利 🚀