本文 AI 產出,尚未審核

ExpressJS (TypeScript) – Validation 請求驗證

型別推導與 Request 型別強化


簡介

在使用 Express 搭配 TypeScript 開發 API 時,最常碰到的問題之一就是 請求資料的型別不明確
雖然 TypeScript 能在編譯階段提供型別安全,但如果直接使用 any 或自行手動轉型,會失去靜態檢查的好處,導致執行時錯誤頻頻出現。

本篇將說明如何透過 型別推導(type inference)與 Request 型別強化(request type augmentation)來建立 可重用、可讀性高且安全的驗證流程。文章從概念切入,搭配 4 個實用範例,最後整理常見陷阱與最佳實踐,幫助你在專案裡快速落地。


核心概念

1. 為什麼要強化 Request 型別?

Express 的 Request 預設型別只有 req.body: anyreq.query: anyreq.params: any
在 TypeScript 中,any 會讓編譯器無法提供任何提示,等於把型別檢查關掉。
透過 自訂介面泛型 把驗證後的資料注入 Request,就能在後續的路由處理函式中直接取得正確的型別資訊。

2. 兩種常見的型別強化方式

方式 特色 典型套件
手寫介面 + 型別斷言 輕量、無外部依賴
使用驗證函式庫 + 型別推導 自動產生型別、減少重複程式 zodyupclass-validatorexpress-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.bodyCreateUserBody,避免每次都寫 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),不影響 paramsquery,保持原有型別不變。


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️⃣ 針對 queryparams 的型別強化

// 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 coerceexpress-validatortoInt(),同時回傳錯誤訊息。
中介層忘記 next() 請求卡住,客戶端永遠不會得到回應。 每條驗證路徑最後一定要呼叫 next(),或回傳錯誤結束流程。

其他實務建議

  1. 把驗證規則抽離成獨立檔案,讓路由檔保持乾淨。
  2. 統一錯誤回應格式(例如 { code, message, details }),方便前端做錯誤處理。
  3. 在測試中加入驗證測試,確保 schema 變更不會破壞既有 API。
  4. 使用 eslint-plugin-importeslint-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 為正整數。
  • 使用 Zodexpress-validatorreq.params.id 轉為 number,若不符合直接回 400。
  • 控制器直接使用 const id = Number(req.params.id),不必再檢查。

總結

  • 型別推導Request 型別強化 是在 Express + TypeScript 專案中提升安全性與開發效率的關鍵。
  • 透過 自訂泛型介面Zod/Yup 等 schema 套件、或 express-validator,皆可在 中介層完成驗證與型別注入,讓路由處理函式只專注於業務邏輯。
  • 避免 any、不完整的斷言與遺漏 next(),並遵循 統一錯誤格式、抽離驗證規則、測試驗證 等最佳實踐,能讓 API 更健全、維護成本更低。

掌握以上技巧後,你就能在 Express 專案中寫出 型別安全、錯誤可預期 的請求驗證程式碼,為前端與後端的協作奠定堅實基礎。祝開發愉快!