ExpressJS (TypeScript) – Validation 請求驗證
型別推導與 Request 型別強化
簡介
在使用 Express 搭配 TypeScript 開發 API 時,最常碰到的問題之一就是 請求資料的型別不明確。
雖然 TypeScript 能在編譯階段提供型別安全,但如果直接使用 any 或自行手動轉型,會失去靜態檢查的好處,導致執行時錯誤頻頻出現。
本篇將說明如何透過 型別推導(type inference)與 Request 型別強化(request type augmentation)來建立 可重用、可讀性高且安全的驗證流程。文章從概念切入,搭配 4 個實用範例,最後整理常見陷阱與最佳實踐,幫助你在專案裡快速落地。
核心概念
1. 為什麼要強化 Request 型別?
Express 的 Request 預設型別只有 req.body: any、req.query: any、req.params: any。
在 TypeScript 中,any 會讓編譯器無法提供任何提示,等於把型別檢查關掉。
透過 自訂介面 或 泛型 把驗證後的資料注入 Request,就能在後續的路由處理函式中直接取得正確的型別資訊。
2. 兩種常見的型別強化方式
| 方式 | 特色 | 典型套件 |
|---|---|---|
| 手寫介面 + 型別斷言 | 輕量、無外部依賴 | – |
| 使用驗證函式庫 + 型別推導 | 自動產生型別、減少重複程式 | zod、yup、class-validator、express-validator |
以下示範兩種方式的實作與型別推導技巧。
程式碼範例
1️⃣ 手寫介面 + 中介層驗證
// types.ts
import { Request } from 'express';
/** 只針對此路由的 body 型別 */
export interface CreateUserBody {
name: string;
email: string;
age?: number;
}
/** 把自訂屬性注入 Request */
export interface TypedRequest<T> extends Request {
body: T;
}
// middleware/validateCreateUser.ts
import { TypedRequest, CreateUserBody } from '../types';
import { Response, NextFunction } from 'express';
/** 簡易驗證函式,失敗拋出 400 */
export function validateCreateUser(
req: TypedRequest<CreateUserBody>,
_res: Response,
next: NextFunction,
) {
const { name, email, age } = req.body;
if (!name || typeof name !== 'string')
return _res.status(400).json({ error: 'Name 必須是字串' });
if (!email || !/^\S+@\S+\.\S+$/.test(email))
return _res.status(400).json({ error: 'Email 格式不正確' });
if (age !== undefined && typeof age !== 'number')
return _res.status(400).json({ error: 'Age 必須是數字' });
// 驗證通過,型別已被「推導」為 CreateUserBody
next();
}
// routes/user.ts
import { Router } from 'express';
import { validateCreateUser } from '../middleware/validateCreateUser';
import { TypedRequest, CreateUserBody } from '../types';
const router = Router();
router.post(
'/users',
validateCreateUser,
(req: TypedRequest<CreateUserBody>, res) => {
// 這裡 TypeScript 已經知道 req.body 的結構
const { name, email, age } = req.body;
// ... 實作建立使用者的商業邏輯
res.status(201).json({ message: `User ${name} created` });
},
);
export default router;
重點:只要在路由函式的參數上使用
TypedRequest<CreateUserBody>,編譯器就會自動推導req.body為CreateUserBody,避免每次都寫as CreateUserBody。
2️⃣ 使用 Zod 產生型別 + 中介層
// validation/userSchema.ts
import { z } from 'zod';
export const createUserSchema = z.object({
name: z.string().min(1, 'Name 不能為空'),
email: z.string().email('Email 格式不正確'),
age: z.number().int().positive().optional(),
});
/** 直接從 Zod 取得 TypeScript 型別 */
export type CreateUserDTO = z.infer<typeof createUserSchema>;
// middleware/zodValidator.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
/**
* 通用的 Zod 中介層,傳入 schema 後會自動把驗證結果注入 req.body
*/
export function zodValidator<T extends ZodSchema<any>>(
schema: T,
) {
return (req: Request, _res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.format();
return _res.status(400).json({ errors });
}
// 把安全的資料覆寫回 req.body,型別已被「推導」為 T 的輸出型別
req.body = result.data;
next();
};
}
// routes/user.ts
import { Router } from 'express';
import { zodValidator } from '../middleware/zodValidator';
import { createUserSchema, CreateUserDTO } from '../validation/userSchema';
import { Request, Response } from 'express';
const router = Router();
router.post(
'/users',
zodValidator(createUserSchema), // ← 只要傳入 schema,即完成驗證 + 型別推導
(req: Request<any, any, CreateUserDTO>, res: Response) => {
// 這裡的 req.body 已是 CreateUserDTO,IDE 會自動補全
const { name, email, age } = req.body;
// ... 實作商業邏輯
res.status(201).json({ message: `User ${name} created` });
},
);
export default router;
技巧:
Request<Params, ResBody, CreateUserDTO>只改變第三個泛型參數(body),不影響params、query,保持原有型別不變。
3️⃣ 結合 express-validator + 型別斷言
// middleware/expressValidator.ts
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/** 定義驗證規則 */
export const createUserRules = [
body('name').isString().notEmpty().withMessage('Name 不能為空'),
body('email').isEmail().withMessage('Email 格式錯誤'),
body('age').optional().isInt({ min: 1 }).withMessage('Age 必須是正整數'),
];
/** 檢查結果並把資料轉型 */
export const validate = (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty())
return res.status(400).json({ errors: errors.array() });
// 這裡手動斷言,因為 express-validator 只能在執行時保證
req.body = {
name: req.body.name,
email: req.body.email,
age: req.body.age ? Number(req.body.age) : undefined,
} as const;
next();
};
// routes/user.ts
import { Router } from 'express';
import { createUserRules, validate } from '../middleware/expressValidator';
import { Request, Response } from 'express';
interface CreateUserBody {
name: string;
email: string;
age?: number;
}
const router = Router();
router.post(
'/users',
createUserRules,
validate,
(req: Request<any, any, CreateUserBody>, res: Response) => {
const { name, email, age } = req.body;
// ... 實作
res.status(201).json({ message: `${name} created` });
},
);
export default router;
提醒:
express-validator本身不提供型別推導,需要自行斷言 (as const或自訂介面) 才能在 TypeScript 中取得完整提示。
4️⃣ 針對 query 與 params 的型別強化
// middleware/queryValidator.ts
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10),
});
export type PaginationQuery = z.infer<typeof paginationSchema>;
export function validatePagination(
req: Request,
_res: Response,
next: NextFunction,
) {
const result = paginationSchema.safeParse(req.query);
if (!result.success) {
return _res.status(400).json({ error: 'Invalid pagination query' });
}
// 覆寫 query,型別已被推導
req.query = result.data;
next();
}
// routes/articles.ts
import { Router, Request, Response } from 'express';
import { validatePagination, PaginationQuery } from '../middleware/queryValidator';
const router = Router();
router.get(
'/articles',
validatePagination,
(req: Request<any, any, any, PaginationQuery>, res: Response) => {
const { page, limit } = req.query; // page、limit 已是 number
// ... 取得分頁資料
res.json({ page, limit, items: [] });
},
);
export default router;
要點:
Request<Params, ResBody, ReqBody, Query>的第四個泛型參數即是query,同理第五個是params,只要在中介層把req.query/req.params重新賦值,型別推導就會自動生效。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 最佳做法 |
|---|---|---|
直接使用 any |
失去 TypeScript 的靜態檢查,執行時才會發現錯誤。 | 盡量使用 自訂介面 或 Zod/Yup 產生的型別。 |
只在路由裡斷言 (as) |
斷言只能掩飾問題,若驗證邏輯寫錯仍會通過。 | 把 驗證與型別推導 放在 中介層,保證每一次進入路由前都已安全。 |
忘記在 express.json() 前使用驗證 |
若 body 為 undefined,驗證函式會拋錯。 |
確保 app.use(express.json()) 於所有路由之前,或在驗證中加上 if (!req.body) …。 |
對 query/params 使用 Number() 直接轉型 |
可能產生 NaN,且 TypeScript 無法捕捉。 |
使用 Zod coerce 或 express-validator 的 toInt(),同時回傳錯誤訊息。 |
中介層忘記 next() |
請求卡住,客戶端永遠不會得到回應。 | 每條驗證路徑最後一定要呼叫 next(),或回傳錯誤結束流程。 |
其他實務建議
- 把驗證規則抽離成獨立檔案,讓路由檔保持乾淨。
- 統一錯誤回應格式(例如
{ code, message, details }),方便前端做錯誤處理。 - 在測試中加入驗證測試,確保 schema 變更不會破壞既有 API。
- 使用
eslint-plugin-import、eslint-plugin-typescript監控未使用的any。
實際應用場景
場景一:會員註冊 API
- 前端送出
username、password、email。 - 使用 Zod 建立
RegisterDTO,中介層自動把req.body轉為RegisterDTO。 - 控制器只負責業務邏輯(hash 密碼、寫入 DB),不必再檢查欄位是否缺失。
場景二:分頁查詢
- 多個列表(文章、商品)共用 page/limit 參數。
- 建立 通用的 pagination 中介層,把
req.query轉為PaginationQuery,所有列表路由只要validatePagination即可。 - 省下重複寫
parseInt(req.query.page)的程式碼。
場景三:路由參數驗證(RESTful)
GET /users/:id必須保證id為正整數。- 使用 Zod 或 express-validator 把
req.params.id轉為number,若不符合直接回 400。 - 控制器直接使用
const id = Number(req.params.id),不必再檢查。
總結
- 型別推導 與 Request 型別強化 是在 Express + TypeScript 專案中提升安全性與開發效率的關鍵。
- 透過 自訂泛型介面、Zod/Yup 等 schema 套件、或 express-validator,皆可在 中介層完成驗證與型別注入,讓路由處理函式只專注於業務邏輯。
- 避免
any、不完整的斷言與遺漏next(),並遵循 統一錯誤格式、抽離驗證規則、測試驗證 等最佳實踐,能讓 API 更健全、維護成本更低。
掌握以上技巧後,你就能在 Express 專案中寫出 型別安全、錯誤可預期 的請求驗證程式碼,為前端與後端的協作奠定堅實基礎。祝開發愉快!