本文 AI 產出,尚未審核

TypeScript 實務開發與架構應用:API 回傳型別(Response DTO)


簡介

在現代前後端分離的開發模式中,API 是前端與後端溝通的唯一橋樑。每一次請求的回傳資料,都會直接影響到 UI 的呈現與功能的正確性。若回傳型別(DTO:Data Transfer Object)沒有被妥善定義與驗證,常會出現「資料缺欄位、型別不符」等問題,導致程式在執行階段拋出錯誤,甚至影響使用者體驗。

TypeScript 以靜態型別系統為基礎,提供了描述 API 回傳結構的能力。透過 Response DTO,我們可以在編譯階段即捕捉到大部分的資料不一致情況,讓開發者更有信心地使用 API,降低除錯成本,也提升團隊協作時的溝通效率。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步建立可維護、可測試的 API 回傳型別。


核心概念

1. 為什麼要使用 Response DTO?

  • 型別安全:編譯期間即檢查回傳資料是否符合預期結構。
  • 自動補完:IDE 能根據 DTO 提供屬性名稱與型別的提示,提升開發效率。
  • 文件生成:使用工具(如 Swagger、NestJS 的 @ApiResponse)可以直接從 DTO 產生 API 文件。
  • 測試便利:在單元測試或 e2e 測試時,直接使用 DTO 作為 mock 資料的型別參考。

小技巧:把 DTO 放在 sharedcommon 資料夾,讓前後端可以共用同一套型別定義(透過 monorepo 或 npm package)。


2. 基本語法:interface vs type

interface type
可擴充性 支援 宣告合併(declaration merging) 只能透過交叉 (&) 合併
可描述 物件結構、函式介面、類別實作 物件、聯合、交叉、映射等更彈性
建議使用情境 需要 擴充實作 的 DTO 複雜型別(如條件型別、映射型別)

實務上:大多數 API DTO 會使用 interface,因為它較易於擴充與維護。但若需要組合多個型別,type 也是不錯的選擇。


3. 基礎範例:單一資源的回傳型別

// src/dto/user-response.dto.ts
export interface UserResponseDto {
  /** 使用者唯一識別碼 */
  id: number;
  /** 使用者名稱 */
  name: string;
  /** 電子郵件,可能為 null */
  email: string | null;
  /** 建立時間(ISO 8601) */
  createdAt: string;
}

說明UserResponseDto 完全描述了後端 /users/:id 端點回傳的 JSON 結構。前端在取得資料後,只要把回傳值斷言為 UserResponseDto,IDE 就會提供完整的屬性提示。


4. 進階範例:分頁列表(Generic 與 Partial)

// src/dto/paginated-response.dto.ts
export interface PaginatedResponseDto<T> {
  /** 當前頁碼 */
  page: number;
  /** 每頁筆數 */
  pageSize: number;
  /** 總筆數 */
  total: number;
  /** 資料列表 */
  items: T[];
}

/* 使用方式 */
export interface ArticleDto {
  id: number;
  title: string;
  author: string;
  publishedAt: string;
}

/* 取得文章分頁 */
type ArticlePageResponse = PaginatedResponseDto<ArticleDto>;

重點:利用 泛型 (<T>) 可以一次定義所有分頁回傳型別,避免重複寫 page, pageSize, total 等欄位。


5. 例子:錯誤回傳型別(Error DTO)

// src/dto/error-response.dto.ts
export interface ErrorResponseDto {
  /** 錯誤代碼(自訂) */
  code: string;
  /** 可讀的錯誤訊息 */
  message: string;
  /** 可能的錯誤細節(如驗證失敗欄位) */
  details?: Record<string, unknown>;
}

/* 範例回傳 */
const exampleError: ErrorResponseDto = {
  code: "USER_NOT_FOUND",
  message: "找不到指定的使用者",
  details: { userId: 123 },
};

實務提示:在 NestJS 中,可使用 @HttpCode@Res 搭配此 DTO,讓錯誤格式保持一致。


6. 程式碼範例彙總

以下示範如何在 AxiosFetch APINestJS Controller 中使用 DTO。

6.1 Axios 呼叫並斷言回傳型別

import axios from "axios";
import { UserResponseDto } from "./dto/user-response.dto";

async function getUser(id: number): Promise<UserResponseDto> {
  const { data } = await axios.get<UserResponseDto>(`/api/users/${id}`);
  // data 已被 TypeScript 推斷為 UserResponseDto
  return data;
}

6.2 Fetch API 搭配型別守衛

import { UserResponseDto } from "./dto/user-response.dto";

function isUserResponse(obj: any): obj is UserResponseDto {
  return (
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    ("email" in obj ? typeof obj.email === "string" || obj.email === null : true) &&
    typeof obj.createdAt === "string"
  );
}

async function fetchUser(id: number): Promise<UserResponseDto> {
  const resp = await fetch(`/api/users/${id}`);
  const json = await resp.json();

  if (!isUserResponse(json)) {
    throw new Error("回傳資料不符合 UserResponseDto");
  }
  return json;
}

6.3 NestJS Controller 回傳 DTO

import { Controller, Get, Param } from "@nestjs/common";
import { ApiOkResponse, ApiNotFoundResponse } from "@nestjs/swagger";
import { UserResponseDto } from "./dto/user-response.dto";
import { ErrorResponseDto } from "./dto/error-response.dto";

@Controller("users")
export class UsersController {
  @Get(":id")
  @ApiOkResponse({ type: UserResponseDto })
  @ApiNotFoundResponse({ type: ErrorResponseDto })
  async findOne(@Param("id") id: number): Promise<UserResponseDto> {
    const user = await this.userService.findById(id);
    // 若找不到會拋出 HttpException,Nest 會自動使用 ErrorResponseDto
    return user;
  }
}

常見陷阱與最佳實踐

陷阱 可能的後果 解決方法
忘記在前端斷言型別 取得 any,失去型別安全 使用 axios.get<T>()as T 或型別守衛
DTO 與資料庫模型不一致 前端顯示錯誤或缺少欄位 建議在後端使用 class-transformer / class-validator 產生 DTO,保持單一來源
過度使用 anyunknown 靜態檢查失效 盡量使用具體型別;若必須使用,配合型別守衛轉換
DTO 直接暴露資料庫欄位 資安風險(例如密碼欄位) 使用 PickOmit 或自行建立「View」DTO
未處理可為 nullundefined 的欄位 執行時錯誤(Cannot read property 明確在 DTO 中標註 `

最佳實踐

  1. 單一責任原則:每個 DTO 只描述單一 API 回傳結構,不要混雜多個端點的欄位。

  2. 共用基礎型別:例如 BaseResponseDto 包含 code, message,其他回傳型別繼承它。

    export interface BaseResponseDto {
      code: number;
      message: string;
    }
    
    export interface UserResponseDto extends BaseResponseDto {
      data: {
        id: number;
        name: string;
        email: string | null;
      };
    }
    
  3. 使用 readonly:若回傳資料不應被修改,宣告為 readonly,提升不可變性。

  4. 自動生成文件:結合 Swagger(NestJS)或 OpenAPI Generator,讓 DTO 成為文件的真實來源。

  5. 測試 DTO:在單元測試中,用 expectTypeOftsd 套件)驗證 DTO 與實際回傳資料的一致性。


實際應用場景

1. 大型電商平台的商品列表

  • 需求:前端需要一次取得多頁商品,每頁 20 筆,且每筆商品包含多種價格、庫存、促銷資訊。
  • 做法:使用 PaginatedResponseDto<ProductDto>,其中 ProductDto 再透過 Pick 只挑選需要顯示的欄位,避免傳遞過多資料。
export type ProductListResponse = PaginatedResponseDto<Pick<ProductDto, "id" | "name" | "price" | "stock">>;

2. 行動 App 的登入流程

  • 需求:登入成功回傳 accessTokenrefreshToken 以及使用者基本資料;失敗回傳統一錯誤格式。
  • 做法:定義兩個 DTO:LoginSuccessDtoErrorResponseDto,在前端使用 union 型別:
type LoginResponse = LoginSuccessDto | ErrorResponseDto;

前端透過 in 檢查 (if ("accessToken" in resp)) 判斷成功與失敗。

3. 微服務間的訊息交換(Kafka / RabbitMQ)

  • 需求:服務 A 產生事件訊息給服務 B,訊息結構必須嚴格對齊。
  • 做法:把 DTO 放在共用的 @my-org/common 套件,兩端皆引用同一個 TypeScript 定義,確保序列化與反序列化時不會出錯。

總結

  • API 回傳型別(Response DTO) 是 TypeScript 在前後端協作中的核心工具,能在編譯階段即捕捉資料不一致問題。
  • 透過 interface / type泛型繼承 等語法,我們可以建立彈性且可擴充的回傳結構。
  • 實作時要注意 型別安全文件同步資料隱私,並遵守最佳實踐(單一責任、共用基礎型別、readonly 等)。
  • 在實務專案中,無論是電商、行動 App、或微服務架構,正確的 DTO 定義都能讓 API 更可靠、開發更順暢、除錯成本更低。

最後提醒:即使 TypeScript 已提供靜態檢查,仍建議在後端使用 class-validatorzod 等運行時驗證方案,雙重保護資料的正確性與安全性。祝你在 TypeScript 的 API 設計路上越走越順!