本文 AI 產出,尚未審核

ExpressJS (TypeScript)

單元:Routing 路由管理

主題:Route 型別定義(RequestHandlerParamsQuery


簡介

在使用 Express 開發 Node.js API 時,路由是最核心的概念。當專案切換到 TypeScript,除了原本的路由字串與中介軟體(middleware)外,我們還需要為 請求處理函式路由參數、以及 查詢字串 提供正確的型別定義。正確的型別不僅能讓編譯器在開發階段即捕捉錯誤,還能在 IDE 中得到自動補完與文件提示,極大提升開發效率與程式碼可讀性。

本篇文章將從 RequestHandlerParamsQuery 三個角度,說明在 Express + TypeScript 環境下如何撰寫 安全、可維護 的路由程式碼。文章適合剛接觸 TypeScript 的前端開發者,也適合想把現有 Express 專案升級為型別安全的後端工程師。


核心概念

1. RequestHandler 的完整型別

RequestHandler 是 Express 提供的通用型別,代表一個接受 RequestResponse、以及 NextFunction 的函式。其基本宣告如下:

type RequestHandler<
  P = ParamsDictionary,
  ResBody = any,
  ReqBody = any,
  ReqQuery = ParsedQs,
  Locals extends Record<string, any> = Record<string, any>
> = (req: Request<P, ResBody, ReqBody, ReqQuery, Locals>, 
   res: Response<ResBody, Locals>, 
   next: NextFunction) => any;
  • P:路由參數的型別(Params)。
  • ResBody:回傳資料的型別。
  • ReqBodyreq.body 的型別,常配合 express.json() 使用。
  • ReqQueryreq.query 的型別(Query)。
  • Localsres.locals 的型別,可用於在中介軟體間傳遞資料。

範例 1:簡單的 GET handler

import { RequestHandler } from 'express';

// 回傳字串的 Response
const helloHandler: RequestHandler<{}, string> = (req, res) => {
  res.send('Hello, Express + TypeScript!')
}

重點:在此例中,我們只指定 ResBodystring,其餘型別使用預設 any,足以應付最簡單的情況。

範例 2:帶有 req.body 的 POST handler

import { RequestHandler } from 'express';

interface CreateUserDto {
  name: string;
  email: string;
  age?: number;
}

const createUser: RequestHandler<{}, { success: boolean; id: number }, CreateUserDto> = (req, res) => {
  const { name, email, age } = req.body; // 取得已型別化的 body
  // 假裝寫入資料庫,回傳新 id
  const newId = Math.floor(Math.random() * 1000);
  res.json({ success: true, id: newId });
}

說明ReqBody 被明確定義為 CreateUserDto,IDE 會即時提示 nameemailage 的屬性,而且 res.json 的回傳型別也被限定為 { success: boolean; id: number }

2. Params:路由參數的型別化

Express 允許在路徑字串中使用 : 來宣告參數,例如 /users/:userId. 若未對參數型別做限制,req.params.userId 預設會是 string | undefined,在實際使用時常需要自行轉型,容易遺漏錯誤。

範例 3:型別化的路由參數

import { RequestHandler } from 'express';

interface UserParams {
  userId: string; // 這裡使用 string,若需要數字可自行轉換
}

const getUser: RequestHandler<UserParams, { name: string; email: string }> = (req, res) => {
  const { userId } = req.params; // 已被 TypeScript 推斷為 string
  // 假設從資料庫取得使用者資料
  const user = { name: 'Alice', email: 'alice@example.com' };
  res.json(user);
}

若想要 自動將參數轉為 number,可以自行建立泛型工具:

type NumericParams<T extends Record<string, any>> = {
  [K in keyof T]: number;
};

interface ProductParams {
  productId: string;
}

// 轉型為數字型別
const getProduct: RequestHandler<NumericParams<ProductParams>> = (req, res) => {
  const { productId } = req.params; // productId 現在是 number
  // ...
}

技巧:把路由參數的型別抽離成介面(interface)或型別別名(type),可以在多個路由間重複使用,維護成本大幅降低。

3. Query:查詢字串的型別化

req.query 預設為 ParsedQs(由 qs 套件解析),其屬性為 string | string[] | undefined。在實務開發中,我們常需要 限定查詢參數的結構,例如分頁 (pagelimit) 或過濾條件 (statuscategory)。

範例 4:分頁查詢的型別

import { RequestHandler } from 'express';

interface ListQuery {
  page?: string;   // 仍以 string 接收,稍後自行轉成 number
  limit?: string;
  sort?: 'asc' | 'desc';
}

const listPosts: RequestHandler<{}, any, any, ListQuery> = (req, res) => {
  const page = Number(req.query.page ?? '1');
  const limit = Number(req.query.limit ?? '10');
  const sort = req.query.sort ?? 'desc';

  // 假設從資料庫抓取分頁結果
  const posts = []; // ... 省略
  res.json({ page, limit, sort, data: posts });
}

若希望 直接得到數字型別,可以在路由層使用 型別守護

function isNumberString(value: any): value is string {
  return typeof value === 'string' && !isNaN(Number(value));
}

const listProducts: RequestHandler<{}, any, any, { page?: string; limit?: string }> = (req, res) => {
  if (req.query.page && !isNumberString(req.query.page)) {
    return res.status(400).json({ error: 'page 必須是數字' });
  }
  const page = Number(req.query.page ?? '1');
  // ...
}

範例 5:混合型別的複雜查詢

import { RequestHandler } from 'express';

interface SearchQuery {
  q: string;                // 關鍵字,必填
  tags?: string[];          // 多個標籤
  startDate?: string;       // ISO 日期字串
  endDate?: string;
}

const searchHandler: RequestHandler<{}, any, any, SearchQuery> = (req, res) => {
  const { q, tags, startDate, endDate } = req.query;

  // 轉型與驗證
  const start = startDate ? new Date(startDate) : undefined;
  const end = endDate ? new Date(endDate) : undefined;
  // 之後把條件傳給搜尋服務...
  res.json({ q, tags, start, end });
}

小技巧tags?: string[] 只在前端以 ?tags=tag1&tags=tag2 形式傳入時才會得到陣列;若只傳一個值,qs 仍會把它包成陣列,除非在 express 初始化時設定 queryParser: 'simple'

4. 結合 Router 與型別安全的中介軟體

在大型專案中,我們會把路由拆成多個 Router,每個 Router 內部仍需要保留型別資訊。下面示範如何把前面的 handler 套用在 Router

import { Router } from 'express';

const userRouter = Router();

// 直接使用已型別化的 handler
userRouter.get('/:userId', getUser);
userRouter.post('/', createUser);

export default userRouter;

若想在 Router 層面 預先限定 req.params,可以使用 泛型重載

declare module 'express-serve-static-core' {
  interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = core.Query> {
    // 重新定義,使得在特定 Router 中自動帶入型別
  }
}

實務上,我們較常採取 介面抽離 + handler 直接注入 的方式,避免全域覆寫造成其他路由的型別衝突。


常見陷阱與最佳實踐

常見問題 可能原因 解決方式
req.params.xxx 顯示 `string undefined` 沒有為 Params 提供明確型別
req.query 的屬性被推斷為 any 未在 RequestHandler 指定 ReqQuery 在 handler 定義中加入 ReqQuery 泛型,例如 <{}, any, any, ListQuery>
中介軟體的 next(err) 失去型別提示 使用原生 NextFunction 而未加上錯誤型別 若自訂錯誤型別,可寫 NextFunction 的擴充版:type Next = (err?: MyError) => void;
express.json() 解析後的 req.bodyany 未提供 ReqBody 型別 為每個需要 body 的路由提供 ReqBody 泛型,或在全域 express 設定中使用 declare global 方式擴充 Request 介面
qs 解析陣列時出現單一字串 前端只傳遞一次相同參數 在前端統一使用 arrayFormat: 'repeat',或在後端自行檢查 Array.isArray()

最佳實踐

  1. 把型別抽離成獨立檔案
    • src/types/params.tssrc/types/query.ts,讓路由檔案保持乾淨。
  2. 使用 zodyup 做驗證,並結合型別推導
    import { z } from 'zod';
    const createUserSchema = z.object({
      name: z.string(),
      email: z.string().email(),
      age: z.number().int().optional(),
    });
    type CreateUserDto = z.infer<typeof createUserSchema>;
    
  3. router 中統一加入錯誤處理中介軟體,確保 next(err) 能被捕捉。
  4. 盡量避免在 handler 內部使用 any,即使是臨時變數,也應該以 unknown 再做型別斷言。
  5. 寫單元測試,尤其是驗證 型別守護(type guard)與 錯誤回傳 的行為。

實際應用場景

1. 會員系統的 CRUD API

  • GET /users/:userId:使用 UserParams 取得單一會員資訊。
  • POST /usersCreateUserDto 驗證後寫入資料庫,回傳新會員 ID。
  • PUT /users/:userId:結合 UserParamsUpdateUserDto,支援部分更新(patch)。

2. 電子商務平台的商品搜尋

  • GET /productsSearchQuery 包含關鍵字、類別、價格區間、排序方式。
  • GET /products/:productId/reviewsProductParams + 分頁查詢 ListQuery,回傳分頁結果。

3. 後台管理系統的報表產生

  • GET /reports/salesReportQuerystartDateendDateregion?
    • 透過型別守護確保日期格式正確,避免因字串錯誤拋出例外。

實務提示:在上述每個 API 中,型別安全的路由 能讓前端開發者在呼叫 API 時,使用 TypeScript 的自動補完,減少錯誤的可能性,亦能在後端快速定位問題根源。


總結

  • RequestHandler 是 Express 路由的核心型別,透過泛型我們可以分別為 路由參數 (Params)請求主體 (ReqBody)查詢字串 (Query) 以及 回傳資料 (ResBody) 提供精確的型別定義。
  • paramsquery 建立專屬介面或型別別名,可讓路由函式在編寫時即得到 IDE 的提示與錯誤檢查。
  • 常見的陷阱多半源於 未指定泛型過度依賴 any,只要遵守「型別第一」的原則,搭配驗證函式庫(如 zod)即可寫出 安全、可維護 的 API。
  • 在大型專案中,將型別抽離、使用 Router 模組化、並在全域統一錯誤處理,都是提升開發效率與程式品質的關鍵。

透過本文的說明與範例,你應該已經掌握在 Express + TypeScript 環境下,如何正確為路由、參數與查詢字串寫出完整的型別定義。未來只要在新建或改寫 API 時,遵循這套型別化流程,就能大幅降低執行時錯誤,並讓團隊協作更順暢。祝開發順利! 🚀