本文 AI 產出,尚未審核

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 並在 Location Header 中提供新資源 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 且無日誌 使用全域錯誤中介軟體,並記錄日誌

最佳實踐

  1. 遵守「資源為名詞」的原則,路由全程使用複數形式。
  2. 統一錯誤回應結構({ error: { code, message } }),讓前端只要檢查 error 是否存在即可。
  3. 在 TypeScript 中使用介面 (interface) 描述請求/回應,配合 express.Request<Params, ResBody, ReqBody> 取得型別安全。
  4. 在成功回傳時明確使用 200、201、204,避免混用。
  5. 將路由路徑抽離為常量,配合 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!