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 可以:
- 讀取或修改
req(例如把驗證後的資料放進req.body)。 - 若驗證失敗,直接回傳錯誤回應,阻止 往下的路由處理。
- 驗證成功後呼叫
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:要驗證的來源(body、query、params),預設為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 中加入 refine 或 superRefine。 |
| 驗證過程中拋出非 ZodError | 會直接走到全域錯誤處理,可能暴露內部細節。 | 在 catch 中先判斷 instanceof ZodError,其餘錯誤交給 next(err)。 |
最佳實踐
- 保持 schema 純粹:盡量只描述資料結構與基本驗證,複雜業務邏輯放在 service 層或
refine中。 - 集中管理 middleware:將
validate、validateAsync放在src/middleware/validation.ts,所有路由統一引用。 - 錯誤訊息國際化:如果專案需要多語系,
ZodError.format()產出的錯誤結構很適合再經過翻譯層處理。 - 測試覆蓋:對每個 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 呼叫都在進入業務邏輯前得到嚴格的保護,從此 錯誤減少、開發更快、維護更安心!祝開發順利 🚀