本文 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 OK201 Created204 No Content 請求成功且有回傳內容、建立資源、刪除成功無回傳
重新導向 3xx 301 Moved Permanently304 Not Modified 永久/暫時 URL 變更、快取驗證
客戶端錯誤 4xx 400 Bad Request401 Unauthorized403 Forbidden404 Not Found409 Conflict422 Unprocessable Entity 請求格式錯誤、未授權、資源不存在、衝突或驗證失敗
伺服器錯誤 5xx 500 Internal Server Error503 Service Unavailable 程式例外、服務暫停等

重點不要 把所有錯誤都回傳 500,這樣前端無法分辨是哪一類問題,最常見的錯誤類別(如 400、404、409)應明確回傳。

3. 結合 TypeScript 的型別安全

使用 TypeScript 可以在 requestresponse 兩端定義明確的型別,讓錯誤的欄位或狀態碼在編譯階段就被捕捉。

// 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
硬寫字串導致拼寫錯誤 小錯誤難以發現,測試失敗 使用 enumconst 集中管理路徑
錯誤訊息過於模糊 (error: "fail") 除錯成本升高 回傳結構化的 error.codeerror.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(前端顯示維護公告)

使用 422403503 等更細分的狀態碼,使得前端 UI 能針對不同情境提供適切的提示,提升使用者體驗。


總結

  • 一致性命名是 RESTful API 的基礎:以資源為名詞、使用集合/單一資源的層級結構、配合 HTTP 方法表達動作。
  • 正確的 HTTP 狀態碼則是 API 與前端溝通的語言,從 2xx 成功、4xx 客戶端錯誤到 5xx 伺服器錯誤,必須依照 RFC 明確回傳。
  • TypeScript 為我們提供了型別安全的保障,透過介面、enum、泛型,可在編譯階段捕捉命名或回應結構的錯誤。
  • 實務上,將路由、控制器、錯誤處理分層,並以統一的錯誤回應格式與常量管理路徑,可大幅降低維護成本與錯誤率。

遵循上述原則與範例,你的 ExpressJS + TypeScript API 不僅在 可讀性可測試性可擴充性 上達到專業水準,也能讓前端開發者在使用時得到清晰、可預測的回饋,提升整體開發效率與使用者體驗。祝你寫出乾淨、可靠的 RESTful API!