ExpressJS (TypeScript) – RESTful API 設計
主題:一致性命名與狀態碼使用
簡介
在 RESTful API 中,命名 及 HTTP 狀態碼 的使用是使用者與開發者溝通的橋樑。即使是功能完整的服務,若回傳的路徑、參數或錯誤碼不一致,也會造成前端開發、測試甚至第三方整合的高度摩擦。
本單元將以 ExpressJS + TypeScript 為基礎,說明如何在專案中落實 一致性命名規則、正確選擇 HTTP 狀態碼,並透過實作範例展示最佳實踐。文章適合剛接觸 Node/Express 的新手,也能為已有經驗的開發者提供可直接套用的參考模型。
核心概念
1. RESTful 命名的基本原則
| 原則 | 說明 | 範例 |
|---|---|---|
| 資源為名詞 | 路由應描述 什麼,而非 要做什麼。 | /users、/orders |
| 集合 vs 單一資源 | 集合使用複數,單一資源使用 ID。 | GET /products(取列表)GET /products/:id(取單筆) |
| 層級結構 | 子資源放在父資源之下,保持階層清晰。 | /users/:userId/orders |
| 動作使用 HTTP 方法 | CRUD 行為交給 GET、POST、PUT、PATCH、DELETE 等方法。 |
POST /users(建立)PATCH /users/:id(部分更新) |
| 避免動詞 | 不要在路由中加入 create、remove、update 等動詞。 |
❌ /createUser ✅ /users(POST) |
小技巧:在 TypeScript 中,常把路由字串抽離成常數或 enum,避免硬寫字串造成拼寫錯誤。
// routes.ts
export enum ApiPath {
Users = '/users',
Orders = '/orders',
}
2. HTTP 狀態碼的意義與選擇
| 類別 | 範圍 | 常用狀態碼 | 何時使用 |
|---|---|---|---|
| 成功 | 2xx | 200 OK、201 Created、204 No Content | 請求成功且有回傳內容、建立資源、刪除成功無回傳 |
| 重新導向 | 3xx | 301 Moved Permanently、304 Not Modified | 永久/暫時 URL 變更、快取驗證 |
| 客戶端錯誤 | 4xx | 400 Bad Request、401 Unauthorized、403 Forbidden、404 Not Found、409 Conflict、422 Unprocessable Entity | 請求格式錯誤、未授權、資源不存在、衝突或驗證失敗 |
| 伺服器錯誤 | 5xx | 500 Internal Server Error、503 Service Unavailable | 程式例外、服務暫停等 |
重點:不要 把所有錯誤都回傳
500,這樣前端無法分辨是哪一類問題,最常見的錯誤類別(如 400、404、409)應明確回傳。
3. 結合 TypeScript 的型別安全
使用 TypeScript 可以在 request、response 兩端定義明確的型別,讓錯誤的欄位或狀態碼在編譯階段就被捕捉。
// types.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
export interface ApiResponse<T> {
data?: T;
error?: {
code: number;
message: string;
};
}
4. 程式碼範例
以下示範四個常見的 API,涵蓋 命名一致性、狀態碼 以及 型別 的完整寫法。
4.1 建立使用者 – POST /users
// user.controller.ts
import { Request, Response } from 'express';
import { User } from './types';
import { v4 as uuidv4 } from 'uuid';
// 假設有一個簡易的記憶體資料庫
const users: Record<string, User> = {};
export const createUser = (req: Request, res: Response) => {
const { name, email } = req.body;
// 基本驗證
if (!name || !email) {
return res.status(400).json({
error: { code: 400, message: 'Name and email are required.' },
});
}
// 檢查 Email 是否已存在(409 Conflict)
const exists = Object.values(users).some(u => u.email === email);
if (exists) {
return res.status(409).json({
error: { code: 409, message: 'Email already in use.' },
});
}
const newUser: User = {
id: uuidv4(),
name,
email,
createdAt: new Date(),
};
users[newUser.id] = newUser;
// 成功建立 → 201 Created,回傳新資源的路徑於 Location Header
res.location(`${req.baseUrl}${req.path}/${newUser.id}`);
return res.status(201).json({ data: newUser });
};
說明:
- 使用 POST 表示「建立」的動作。
- 回傳 201 並在
LocationHeader 中提供新資源 URI,符合 RFC 7231。- 錯誤時使用 400(缺少欄位)或 409(衝突),讓前端能依據狀態碼給予不同的 UI 反饋。
4.2 取得單一使用者 – GET /users/:id
// user.controller.ts (續)
export const getUser = (req: Request, res: Response) => {
const { id } = req.params;
const user = users[id];
if (!user) {
// 404 Not Found:資源不存在
return res.status(404).json({
error: { code: 404, message: `User with id ${id} not found.` },
});
}
// 成功 → 200 OK,返回完整資料
return res.status(200).json({ data: user });
};
4.3 更新使用者(部分) – PATCH /users/:id
export const updateUser = (req: Request, res: Response) => {
const { id } = req.params;
const user = users[id];
if (!user) {
return res.status(404).json({
error: { code: 404, message: `User with id ${id} not found.` },
});
}
const { name, email } = req.body;
// 若傳入的 email 已被其他使用者佔用,回傳 409
if (email && Object.values(users).some(u => u.email === email && u.id !== id)) {
return res.status(409).json({
error: { code: 409, message: 'Email already in use by another user.' },
});
}
// 只更新有提供的欄位
if (name) user.name = name;
if (email) user.email = email;
return res.status(200).json({ data: user });
};
重點:PATCH 用於「部分」更新,回傳 200(或 204 No Content)皆可。若不回傳內容,建議使用 204 並省略
response body。
4.4 刪除使用者 – DELETE /users/:id
export const deleteUser = (req: Request, res: Response) => {
const { id } = req.params;
if (!users[id]) {
return res.status(404).json({
error: { code: 404, message: `User with id ${id} not found.` },
});
}
delete users[id];
// 成功刪除 → 204 No Content,表示請求成功但不返回內容
return res.sendStatus(204);
};
4.5 統一錯誤處理 Middleware
// error.middleware.ts
import { Request, Response, NextFunction } from 'express';
export const errorHandler = (
err: Error,
_req: Request,
res: Response,
_next: NextFunction
) => {
console.error('Unhandled Error:', err);
// 任何未捕捉的例外都統一回傳 500
res.status(500).json({
error: { code: 500, message: 'Internal Server Error' },
});
};
說明:將 500 統一交給全域錯誤中介軟體,避免在每個 controller 中重複寫
try/catch。
5. 把路由與控制器分離,保持命名一致
// user.routes.ts
import { Router } from 'express';
import {
createUser,
getUser,
updateUser,
deleteUser,
} from './user.controller';
import { ApiPath } from './routes';
const router = Router();
router.post(ApiPath.Users, createUser); // POST /users
router.get(`${ApiPath.Users}/:id`, getUser); // GET /users/:id
router.patch(`${ApiPath.Users}/:id`, updateUser); // PATCH /users/:id
router.delete(`${ApiPath.Users}/:id`, deleteUser); // DELETE /users/:id
export default router;
常見陷阱與最佳實踐
| 陷阱 | 為何會發生 | 解決方式 |
|---|---|---|
在路由中使用動詞 (/getUser) |
讓 API 失去語意一致性 | 改用資源名稱 + HTTP 方法 |
回傳不正確的狀態碼 (200 代表錯誤) |
前端無法根據狀態碼做分支 | 依照 RFC 7231 正確對應 4xx/5xx |
| 硬寫字串導致拼寫錯誤 | 小錯誤難以發現,測試失敗 | 使用 enum 或 const 集中管理路徑 |
錯誤訊息過於模糊 (error: "fail") |
除錯成本升高 | 回傳結構化的 error.code、error.message |
在成功回應時同時回傳 error 欄位 |
讓 API 規格不一致 | 成功回傳只包含 data,失敗回傳只包含 error |
| 未處理未捕捉的例外 | 程式崩潰、回傳 500 且無日誌 | 使用全域錯誤中介軟體,並記錄日誌 |
最佳實踐
- 遵守「資源為名詞」的原則,路由全程使用複數形式。
- 統一錯誤回應結構(
{ error: { code, message } }),讓前端只要檢查error是否存在即可。 - 在 TypeScript 中使用介面 (
interface) 描述請求/回應,配合express.Request<Params, ResBody, ReqBody>取得型別安全。 - 在成功回傳時明確使用 200、201、204,避免混用。
- 將路由路徑抽離為常量,配合 Lint 規則防止拼寫錯誤。
實際應用場景
場景一:電商平台的商品 API
- 資源:
/products、/products/:id、/products/:id/reviews - 需求:
- 建立商品 →
POST /products(回傳 201 +Location) - 取得商品列表 →
GET /products?page=1&limit=20(回傳 200) - 更新庫存 →
PATCH /products/:id(回傳 200) - 刪除商品 →
DELETE /products/:id(回傳 204)
- 建立商品 →
透過一致的命名與正確的狀態碼,前端可以根據 201 判斷「新商品已建立」並自動跳轉至該商品頁面;204 則代表「刪除成功」可直接移除列表項目。
堽景二:社群平台的關注(Follow)功能
- 資源:
/users/:userId/followers、/users/:userId/following - 操作:
- 追蹤 →
POST /users/:userId/following/:targetId(回傳 201) - 取消追蹤 →
DELETE /users/:userId/following/:targetId(回傳 204) - 取得追蹤列表 →
GET /users/:userId/following(回傳 200)
- 追蹤 →
在此情境下,201 表示「關係已建立」且可在回應中提供關係 ID;204 代表「關係已刪除」且不需要任何內容,減少網路流量。
場景三:企業內部系統的資料驗證
- 資源:
/employees、/employees/:id - 常見錯誤:
- 欄位格式錯誤 →
422 Unprocessable Entity(前端可直接顯示欄位錯誤訊息) - 權限不足 →
403 Forbidden(前端導向授權流程) - 服務維護 →
503 Service Unavailable(前端顯示維護公告)
- 欄位格式錯誤 →
使用 422、403、503 等更細分的狀態碼,使得前端 UI 能針對不同情境提供適切的提示,提升使用者體驗。
總結
- 一致性命名是 RESTful API 的基礎:以資源為名詞、使用集合/單一資源的層級結構、配合 HTTP 方法表達動作。
- 正確的 HTTP 狀態碼則是 API 與前端溝通的語言,從 2xx 成功、4xx 客戶端錯誤到 5xx 伺服器錯誤,必須依照 RFC 明確回傳。
- TypeScript 為我們提供了型別安全的保障,透過介面、enum、泛型,可在編譯階段捕捉命名或回應結構的錯誤。
- 實務上,將路由、控制器、錯誤處理分層,並以統一的錯誤回應格式與常量管理路徑,可大幅降低維護成本與錯誤率。
遵循上述原則與範例,你的 ExpressJS + TypeScript API 不僅在 可讀性、可測試性、可擴充性 上達到專業水準,也能讓前端開發者在使用時得到清晰、可預測的回饋,提升整體開發效率與使用者體驗。祝你寫出乾淨、可靠的 RESTful API!