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 放在 shared 或 common 資料夾,讓前後端可以共用同一套型別定義(透過 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. 程式碼範例彙總
以下示範如何在 Axios、Fetch API 與 NestJS 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,保持單一來源 |
過度使用 any 或 unknown |
靜態檢查失效 | 盡量使用具體型別;若必須使用,配合型別守衛轉換 |
| DTO 直接暴露資料庫欄位 | 資安風險(例如密碼欄位) | 使用 Pick、Omit 或自行建立「View」DTO |
未處理可為 null 或 undefined 的欄位 |
執行時錯誤(Cannot read property) |
明確在 DTO 中標註 ` |
最佳實踐
單一責任原則:每個 DTO 只描述單一 API 回傳結構,不要混雜多個端點的欄位。
共用基礎型別:例如
BaseResponseDto包含code,message,其他回傳型別繼承它。export interface BaseResponseDto { code: number; message: string; } export interface UserResponseDto extends BaseResponseDto { data: { id: number; name: string; email: string | null; }; }使用
readonly:若回傳資料不應被修改,宣告為readonly,提升不可變性。自動生成文件:結合 Swagger(NestJS)或 OpenAPI Generator,讓 DTO 成為文件的真實來源。
測試 DTO:在單元測試中,用
expectTypeOf(tsd套件)驗證 DTO 與實際回傳資料的一致性。
實際應用場景
1. 大型電商平台的商品列表
- 需求:前端需要一次取得多頁商品,每頁 20 筆,且每筆商品包含多種價格、庫存、促銷資訊。
- 做法:使用
PaginatedResponseDto<ProductDto>,其中ProductDto再透過Pick只挑選需要顯示的欄位,避免傳遞過多資料。
export type ProductListResponse = PaginatedResponseDto<Pick<ProductDto, "id" | "name" | "price" | "stock">>;
2. 行動 App 的登入流程
- 需求:登入成功回傳
accessToken、refreshToken以及使用者基本資料;失敗回傳統一錯誤格式。 - 做法:定義兩個 DTO:
LoginSuccessDto、ErrorResponseDto,在前端使用 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-validator 或 zod 等運行時驗證方案,雙重保護資料的正確性與安全性。祝你在 TypeScript 的 API 設計路上越走越順!