ExpressJS (TypeScript)
單元:Routing 路由管理
主題:Route 型別定義(RequestHandler、Params、Query)
簡介
在使用 Express 開發 Node.js API 時,路由是最核心的概念。當專案切換到 TypeScript,除了原本的路由字串與中介軟體(middleware)外,我們還需要為 請求處理函式、路由參數、以及 查詢字串 提供正確的型別定義。正確的型別不僅能讓編譯器在開發階段即捕捉錯誤,還能在 IDE 中得到自動補完與文件提示,極大提升開發效率與程式碼可讀性。
本篇文章將從 RequestHandler、Params、Query 三個角度,說明在 Express + TypeScript 環境下如何撰寫 安全、可維護 的路由程式碼。文章適合剛接觸 TypeScript 的前端開發者,也適合想把現有 Express 專案升級為型別安全的後端工程師。
核心概念
1. RequestHandler 的完整型別
RequestHandler 是 Express 提供的通用型別,代表一個接受 Request、Response、以及 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:回傳資料的型別。ReqBody:req.body的型別,常配合express.json()使用。ReqQuery:req.query的型別(Query)。Locals:res.locals的型別,可用於在中介軟體間傳遞資料。
範例 1:簡單的 GET handler
import { RequestHandler } from 'express';
// 回傳字串的 Response
const helloHandler: RequestHandler<{}, string> = (req, res) => {
res.send('Hello, Express + TypeScript!')
}
重點:在此例中,我們只指定
ResBody為string,其餘型別使用預設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 會即時提示name、age的屬性,而且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。在實務開發中,我們常需要 限定查詢參數的結構,例如分頁 (page、limit) 或過濾條件 (status、category)。
範例 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.body 為 any |
未提供 ReqBody 型別 |
為每個需要 body 的路由提供 ReqBody 泛型,或在全域 express 設定中使用 declare global 方式擴充 Request 介面 |
qs 解析陣列時出現單一字串 |
前端只傳遞一次相同參數 | 在前端統一使用 arrayFormat: 'repeat',或在後端自行檢查 Array.isArray() |
最佳實踐
- 把型別抽離成獨立檔案
src/types/params.ts、src/types/query.ts,讓路由檔案保持乾淨。
- 使用
zod或yup做驗證,並結合型別推導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>; - 在
router中統一加入錯誤處理中介軟體,確保next(err)能被捕捉。 - 盡量避免在 handler 內部使用
any,即使是臨時變數,也應該以unknown再做型別斷言。 - 寫單元測試,尤其是驗證 型別守護(type guard)與 錯誤回傳 的行為。
實際應用場景
1. 會員系統的 CRUD API
- GET
/users/:userId:使用UserParams取得單一會員資訊。 - POST
/users:CreateUserDto驗證後寫入資料庫,回傳新會員 ID。 - PUT
/users/:userId:結合UserParams與UpdateUserDto,支援部分更新(patch)。
2. 電子商務平台的商品搜尋
- GET
/products:SearchQuery包含關鍵字、類別、價格區間、排序方式。 - GET
/products/:productId/reviews:ProductParams+ 分頁查詢ListQuery,回傳分頁結果。
3. 後台管理系統的報表產生
- GET
/reports/sales:ReportQuery(startDate、endDate、region?)- 透過型別守護確保日期格式正確,避免因字串錯誤拋出例外。
實務提示:在上述每個 API 中,型別安全的路由 能讓前端開發者在呼叫 API 時,使用 TypeScript 的自動補完,減少錯誤的可能性,亦能在後端快速定位問題根源。
總結
RequestHandler是 Express 路由的核心型別,透過泛型我們可以分別為 路由參數 (Params)、請求主體 (ReqBody)、查詢字串 (Query) 以及 回傳資料 (ResBody) 提供精確的型別定義。- 為
params、query建立專屬介面或型別別名,可讓路由函式在編寫時即得到 IDE 的提示與錯誤檢查。 - 常見的陷阱多半源於 未指定泛型 或 過度依賴
any,只要遵守「型別第一」的原則,搭配驗證函式庫(如zod)即可寫出 安全、可維護 的 API。 - 在大型專案中,將型別抽離、使用 Router 模組化、並在全域統一錯誤處理,都是提升開發效率與程式品質的關鍵。
透過本文的說明與範例,你應該已經掌握在 Express + TypeScript 環境下,如何正確為路由、參數與查詢字串寫出完整的型別定義。未來只要在新建或改寫 API 時,遵循這套型別化流程,就能大幅降低執行時錯誤,並讓團隊協作更順暢。祝開發順利! 🚀