本文 AI 產出,尚未審核

Express + TypeScript 型別設計


簡介

在 Node.js 生態系統中,Express 是最常見的 Web 框架,而 TypeScript 則提供靜態型別、編譯時錯誤檢查與更好的 IDE 體驗。把兩者結合,不只可以寫出結構清晰、可維護的程式碼,還能在開發階段即捕捉到許多常見的錯誤,提升團隊的開發效率與程式品質。

本篇文章針對 Express + TypeScript 的型別設計進行實務說明,從專案初始化、路由型別、請求/回應物件的擴充,到錯誤處理與中介軟體(middleware)的型別寫法,都提供具體範例與最佳實踐,協助讀者在日常開發中快速上手並避免常見陷阱。


核心概念

1️⃣ 初始化 TypeScript + Express 專案

先建立一個最小的專案結構,再透過 ts-node-dev 或 nodemon 讓 TypeScript 直接在開發環境執行。

# 建立目錄
mkdir ts-express-demo && cd ts-express-demo

# 初始化 npm、安裝依賴
npm init -y
npm i express
npm i -D typescript ts-node-dev @types/node @types/express

# 產生 tsconfig.json(建議使用 strict 模式)
npx tsc --init --strict

package.json 中加入啟動腳本:

{
  "scripts": {
    "dev": "ts-node-dev --respawn src/index.ts"
  }
}

Tip:--respawn 讓程式在檔案變動時自動重新啟動,開發時非常便利。


2️⃣ 基本的 Express 伺服器(型別完整)

// src/index.ts
import express, { Application, Request, Response, NextFunction } from 'express';

const app: Application = express();
const PORT = process.env.PORT ?? 3000;

// 內建 JSON 中介軟體
app.use(express.json());

// 測試路由
app.get('/', (req: Request, res: Response) => {
  res.send('Hello TypeScript + Express!');
});

// 全域錯誤處理
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err);
  res.status(500).json({ message: err.message });
});

app.listen(PORT, () => console.log(`🚀 Server listening on ${PORT}`));
  • Application、Request、Response、NextFunction 都是 @types/express 提供的型別,使用它們可以讓 IDE 自動補全 HTTP 方法、路由參數等資訊。

3️⃣ 路由參數與查詢字串的型別

3.1 路由參數(Path Params)

// src/routes/user.ts
import { Router, Request, Response } from 'express';

const router = Router();

/**
 * GET /users/:id
 * :id 為路由參數,預期為數字
 */
router.get('/:id', (req: Request<{ id: string }>, res: Response) => {
  const userId = Number(req.params.id); // 轉型為 number
  if (Number.isNaN(userId)) {
    return res.status(400).json({ error: 'Invalid user id' });
  }
  // 假設有 getUserById 函式
  // const user = getUserById(userId);
  res.json({ id: userId, name: 'John Doe' });
});

export default router;
  • Request<{ id: string }> 透過 泛型 指定 params 的型別,避免 req.params.id 被推斷為 any。

3.2 查詢字串(Query Params)

// src/routes/search.ts
import { Router, Request, Response } from 'express';

const router = Router();

interface SearchQuery {
  q: string;          // 關鍵字
  page?: number;      // 第幾頁,預設 1
  limit?: number;     // 每頁筆數,預設 10
}

/**
 * GET /search?q=xxx&page=2
 */
router.get('/', (req: Request<{}, {}, {}, SearchQuery>, res: Response) => {
  const { q, page = 1, limit = 10 } = req.query;
  // 直接使用 q、page、limit,型別已被推斷為 string | number
  res.json({ q, page, limit });
});

export default router;
  • Request<{}, {}, {}, SearchQuery> 的四個泛型分別對應 Params、ResBody、ReqBody、Query,此寫法讓 req.query 具備完整型別。

4️⃣ Request Body(DTO)與驗證

在實務開發中,通常會使用 DTO(Data Transfer Object) 來描述請求的 JSON 結構,配合 class-validator 或 zod 進行驗證。

npm i class-validator class-transformer
npm i -D @types/class-validator
// src/dto/CreateUserDto.ts
import { IsEmail, IsInt, IsString, Min, Max } from 'class-validator';

export class CreateUserDto {
  @IsString()
  name!: string;

  @IsEmail()
  email!: string;

  @IsInt()
  @Min(0)
  @Max(150)
  age!: number;
}
// src/middleware/validation.ts
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { Request, Response, NextFunction } from 'express';

export function validateDto<T>(dtoClass: new () => T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const dtoObj = plainToInstance(dtoClass, req.body);
    validate(dtoObj).then((errors: ValidationError[]) => {
      if (errors.length > 0) {
        const msgs = errors.map(e => Object.values(e.constraints ?? {})).flat();
        return res.status(400).json({ errors: msgs });
      }
      // 把已驗證過的物件掛在 req.body 上,後續可直接使用正確型別
      req.body = dtoObj as unknown as T;
      next();
    });
  };
}
// src/routes/user.ts(續)
import { Router, Request, Response } from 'express';
import { CreateUserDto } from '../dto/CreateUserDto';
import { validateDto } from '../middleware/validation';

const router = Router();

router.post(
  '/',
  validateDto(CreateUserDto),   // ← 中介軟體驗證
  (req: Request<{}, {}, CreateUserDto>, res: Response) => {
    const { name, email, age } = req.body; // 已是正確型別
    // TODO: 實作建立使用者的商業邏輯
    res.status(201).json({ id: 1, name, email, age });
  }
);

export default router;

重點:使用 DTO + 中介軟體,不僅讓型別更嚴謹,也能在 runtime 捕捉不符合規範的資料,提升 API 的安全性。


5️⃣ 中介軟體(Middleware)的型別

自訂中介軟體的簽名與 Express 內建的 RequestHandler 完全相同,只要正確標註泛型即可。

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';

export interface AuthPayload {
  userId: number;
  role: 'admin' | 'user';
}

/**
 * 假設從 Header 取得 JWT,解碼後放入 req.auth
 */
export function authMiddleware(
  req: Request,
  _res: Response,
  next: NextFunction
) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return _res.status(401).json({ message: 'Missing token' });
  }
  // 這裡省略 JWT 驗證,直接模擬 payload
  const payload: AuthPayload = { userId: 123, role: 'admin' };
  // 使用 TypeScript 的斷言把自訂屬性加到 req 上
  (req as Request & { auth: AuthPayload }).auth = payload;
  next();
}

在路由中取用:

router.get(
  '/admin',
  authMiddleware,
  (req: Request & { auth: AuthPayload }, res: Response) => {
    if (req.auth.role !== 'admin') {
      return res.status(403).json({ message: 'Forbidden' });
    }
    res.json({ secret: 'admin data' });
  }
);

技巧:若大量使用自訂屬性,建議透過 模組擴充(declaration merging)一次性為 express-serve-static-core 添加屬性,避免每次都寫斷言。

// src/types/express.d.ts
declare namespace Express {
  interface Request {
    auth?: AuthPayload;
  }
}

加入 tsconfig.json 的 typeRoots 設定後,所有檔案即可直接使用 req.auth。


常見陷阱與最佳實踐

陷阱 說明 解法
any 蔓延 直接使用 req.body、req.params,TS 會推斷為 any,失去型別保護。 使用 泛型 為 Request 指定 Params、Query、Body 型別,或透過 DTO 中介軟體轉型。
express.json() 失效 忘記在 app.use(express.json()) 前掛載路由,導致 req.body 為 undefined。 確保 JSON 中介軟體 為第一個被掛載的 middleware。
中介軟體順序錯誤 驗證或認證 middleware 放在路由之後,導致未經檢查就執行業務邏輯。 按 先驗證/認證 → 再執行業務 的順序掛載。
錯誤處理不完整 只寫 app.use((err, req, res, next) => …),但未在 async route 中 next(err),錯誤會被吞掉。 使用 async‑wrapper 或 express-async-errors 套件,讓 async 錯誤自動傳遞至錯誤處理器。
自訂屬性未宣告 在 req 上直接加 req.user,TS 會報錯。 透過 module augmentation(如上 express.d.ts)一次性擴充型別。

最佳實踐

  1. 開啟 strict:在 tsconfig.json 中啟用 strict、noImplicitAny,讓型別錯誤更早顯現。
  2. 使用 DTO + class‑validator:分離驗證與型別,保持控制器簡潔。
  3. 模組化路由:每個資源(user、product …)各自建立 router,並在 index.ts 中集中掛載。
  4. 統一錯誤格式:自訂 Error 類別,讓前端只需要處理同一種錯誤結構。
  5. 單元測試 + 型別檢查:使用 ts-jest 或 vitest,在測試中同時驗證型別與行為。

實際應用場景

1️⃣ 建立企業級 RESTful API

在大型專案中,常見需求包括 分層架構(Controller → Service → Repository)以及 角色授權。透過上面介紹的型別設計,我們可以:

  • Controller:只負責接收 req、回傳 res,所有參數已經是正確型別。
  • Service:接收 DTO,執行業務邏輯,回傳 Domain Model。
  • Repository:與資料庫(如 Prisma、TypeORM)互動,回傳 Entity,型別在整條鏈路上保持一致。

2️⃣ 微服務與訊息佇列

當 Express 作為 API Gateway,需要把請求轉發至 Kafka、RabbitMQ 等訊息佇列時,型別可以保證 訊息結構 與 API 輸入 完全對應,減少序列化/反序列化錯誤。

// src/kafka/producer.ts
import { Kafka, Producer } from 'kafkajs';
import { OrderCreatedEvent } from '../events/OrderCreatedEvent';

const kafka = new Kafka({ clientId: 'order-service', brokers: ['localhost:9092'] });
export const producer: Producer = kafka.producer();

export async function publishOrderCreated(event: OrderCreatedEvent) {
  await producer.send({
    topic: 'order.created',
    messages: [{ value: JSON.stringify(event) }],
  });
}

OrderCreatedEvent 以 TypeScript interface 定義,確保所有屬性在產生訊息前已被檢查。

3️⃣ 前端與後端共用型別

利用 ts-node 或 tsc 產出 .d.ts,前端(React、Vue)可直接 import type { User } from '../server/src/types',實現 前後端型別同步,減少 API 變更帶來的錯誤。


總結

Express + TypeScript 的型別設計不只是寫程式時的輔助工具,更是 提升專案可維護性、降低錯誤率 的關鍵。從 專案初始化、路由參數/查詢字串/請求 Body 的嚴謹型別,到 DTO + 中介軟體驗證、自訂中介軟體的型別擴充,每一步都能讓開發者在編譯階段即捕捉潛在問題。

同時,避免 any 洩漏、正確安排 middleware 的順序、利用 module augmentation 為 req 加上自訂屬性,都是在實務開發中常見的陷阱與解法。結合上述最佳實踐,你可以快速構建 企業級的 RESTful API、支援 微服務訊息佇列,甚至與前端共享型別,讓整個開發流程更流暢、可靠。

祝你在 TypeScript 與 Express 的世界中寫出更安全、更易維護的程式碼! 🚀