iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 29 篇

Day 29|失準的 API 文件:為什麼有了裝飾器,Swagger 還是無法完整還原你的資料結構?

  • 分享至 

  • xImage
  •  

昨天我們談到 DTO 欄位為何會從 Swagger 文件裡消失。今天來探討另一種狀況:欄位有順利出現、文件也能正常產生,但 schema 描述的型別,卻與程式實際接收的型別有落差。

這類問題通常不會引發系統錯誤,Swagger UI 也能正常顯示,但產出的 API 規格卻與實際資料結構不一致。今天我們就來拆解這類Swagger 有抓到部分型別,卻沒有完整還原的問題。

底層解析:@ApiProperty() 能推導出哪些型別?

NestJS Swagger 可以透過 TypeScript 編譯時產生的 metadata,取得部分型別資訊,再用來建立 OpenAPI Schema。不過,TypeScript 的型別資訊並不會在編譯後完整保留下來:

https://ithelp.ithome.com.tw/upload/images/20261013/20184306y7dSNblZvI.png

使用 NestJS CLI 建立的專案,通常預設會在 tsconfig.json 中開啟 emitDecoratorMetadata。本篇範例也沿用此設定,經過編譯後,各類欄位大概會留下這樣的 design:type 資訊:

title: string;                               // String
viewCount: number;                           // Number
published: boolean;                          // Boolean
createdAt: Date;                             // Date
tags: TagDto[];                              // Array
channels: PublishChannel[];                  // Array
externalRef: string | number;                // Object
keywordGroups: string[][];                   // Array

從上面的對應可以看出,string、number 等基本型別通常能保留足夠的資訊,但遇到陣列或聯合型別時,編譯後可能只剩下 Array 或 Object,原本的元素型別與巢狀結構等細節就無法直接得知。

這就導致了以下四種常見的情況:

  1. DTO 陣列被描述成字串陣列
  2. enum 陣列被描述成單一值
  3. 聯合型別變成 object,二維陣列少一層
  4. 循環參照的 DTO 無法正確產生 $ref

接下來就透過實際範例,看看這些問題是怎麼發生的,以及該如何修正。

現象一:知道它是陣列,卻不知道裡面裝什麼

問題怎麼發生?

假設文章的標籤希望同時紀錄名稱與代稱,因此我們使用 TagDto 來定義每個標籤:

export class TagDto {
  @ApiProperty({ description: '標籤名稱' })
  name: string;

  @ApiProperty({ description: '標籤代稱' })
  slug: string;
}

export class PostDto {
  // ...
  @ApiProperty()
  tags: TagDto[];
}

在未額外指定元素型別的情況下,NestJS Swagger 可能將未知的陣列元素描述成 string:

{
  "type": "array",
  "items": { "type": "string" }
}

https://ithelp.ithome.com.tw/upload/images/20261013/20184306ijz0IuTagd.png

在這種情況下,Swagger 已經認定陣列元素是字串,自然不會繼續沿著這個欄位去解析 TagDto,導致 TagDto 可能完全不會出現在 components.schemas 中。

根因:陣列容器與內部元素是兩組獨立資訊

在 TypeScript 的原始碼中,TagDto[] 同時表達了「外層是陣列」與「元素是 TagDto」兩件事。但經過編譯後,執行期 metadata 只保住了前者。@ApiProperty() 看得見容器,卻無法自動還原被擦除的元素型別。

這也解釋了為什麼有時候寫 string[] 會看似正常運作:這並不是 Swagger 完整推導出了 string,而是未知元素的預設值剛好也是字串。一旦換成 DTO 或其他型別,這個巧合就會失效。

排雷指南

必須明確告訴 Swagger 陣列的元素型別:

@ApiProperty({ type: [TagDto] })
tags: TagDto[];

或者也可以將 type 與 isArray 拆開來寫:

@ApiProperty({
  type: TagDto,
  isArray: true,
})
tags: TagDto[];

這兩種寫法都能正確描述 TagDto 陣列,讓 Swagger 產生陣列結構,並透過 $ref 參照 TagDto 的 schema。

現象二:enum 有值域,卻少了陣列結構

問題怎麼發生?

假設文章可以同時發布到網站、App 與電子報,我們使用 enum 定義這些發布通路:

export enum PublishChannel {
  Web = 'web',
  App = 'app',
  Newsletter = 'newsletter',
}

export class PostDto {
  // ...
  @ApiProperty({ enum: PublishChannel })
  channels: PublishChannel[];
}

這時 enum 設定已經列出了合法值,但產出的 schema 卻可能變成單一字串:

{
  "type": "string",
  "enum": ["web", "app", "newsletter"]
}

https://ithelp.ithome.com.tw/upload/images/20261013/20184306QwlOvEMJk4.png

Swagger 將欄位描述成單一 enum 值,但實際上應該是 enum 陣列。如果前端根據這份 OpenAPI 規格產生 API client,就可能得到錯誤的型別定義。

根因:值域設定與陣列結構是不同的概念

處理 enum 欄位時,enum 只負責定義這個欄位允許哪些值。如同上一節提到的,PublishChannel[] 在執行期只剩下 Array 的資訊,原始碼裡的 [] 無法自動替 enum 選項補上陣列結構。

排雷指南

如果是 enum 陣列,必須補上 isArray 屬性:

@ApiProperty({
  enum: PublishChannel,
  enumName: 'PublishChannel',
  isArray: true,
})
channels: PublishChannel[];

另外,加上 enumName 雖非型別正確的絕對必要條件,但它能讓 enum 成為 components.schemas 裡的具名 schema。當多個欄位共用同一個 enum 時,文件能統一使用 $ref 參照,避免產生多份內容相同卻名稱各異的匿名 enum。

現象三:聯合型別變成 object,二維陣列少了一層

問題怎麼發生?

除了基本型別與一般陣列,DTO 也可能需要描述聯合型別或多維陣列,例如外部系統的 ID 可能是字串或數字,而 SEO 關鍵字也可能依照不同群組保存:

export class PostDto {
  // ...
  @ApiProperty()
  externalRef: string | number;

  @ApiProperty()
  keywordGroups: string[][];
}

TypeScript 可以理解這兩個欄位,但在執行期,它們留下的 metadata 分別只剩下 Object 與 Array。最後 Swagger 產出的結果可能會是:

{
  "externalRef": { "type": "object" },
  "keywordGroups": {
    "type": "array",
    "items": { "type": "string" }
  }
}

https://ithelp.ithome.com.tw/upload/images/20261013/201843069htUXftmNh.png

externalRef 的 OpenAPI schema 無法正確表達原本的字串或數字型別;而 keywordGroups 原本是二維陣列,但產出的 schema 只描述了一維陣列,少了一層巢狀結構。

根因:單一型別資訊無法完整表達複合結構

string | number 代表了多種可能,無法對應到單一的 JavaScript 建構子,因此 metadata 退而求其次,只留下最寬鬆的 Object。

多維陣列則是陣列問題的延伸:string[][] 不僅遺失了最內層的元素型別,也遺失了陣列的層級資訊。這些型別無法單靠 design:type 完整描述,因此需要額外提供 OpenAPI schema 資訊。

排雷指南

處理聯合型別時,使用 oneOf 來逐一列出允許的 schema:

@ApiProperty({
  oneOf: [
    { type: 'string' },
    { type: 'number' },
  ],
})
externalRef: string | number;

處理多維陣列時,則需使用 items 把每一層的結構寫清楚:

@ApiProperty({
  type: 'array',
  items: {
    type: 'array',
    items: { type: 'string' },
  },
})
keywordGroups: string[][];

避免將一維陣列的簡寫直接延伸寫成 type: [[String]],這不僅不符合 ApiPropertyOptions 的型別定義,TypeScript 編譯時也會報錯。遇到矩陣或多維陣列,直接改用原生的 type: 'array' 搭配巢狀 items,結構反而會更清晰。

現象四:循環 DTO 的一邊正常,另一邊卻變成 object

問題怎麼發生?

假設 PostDto 裡有 AuthorDto,而 AuthorDto 又包含 PostDto:

// post.dto.ts
export class PostDto {
  // ...
  @ApiProperty({ type: AuthorDto })
  author: AuthorDto;
}

// author.dto.ts
export class AuthorDto {
  // ...
  @ApiProperty({ type: PostDto })
  featuredPost: PostDto;
}

https://ithelp.ithome.com.tw/upload/images/20261013/2018430641ZfurkycT.png

兩個欄位都已經透過 type 明確指出對方的 DTO,看起來不再需要依賴自動推導。但在本篇環境中,PostDto.author 會產生正確的 $ref,AuthorDto.featuredPost 卻仍然只剩 { "type": "object" }。

問題不在於「有沒有指定 type」,而是「什麼時候讀取 type」。

根因:取得型別時,class 可能尚未完成初始化

使用 type: PostDto 這種寫法,意味著裝飾器在建立當下就會立刻讀取傳入的 class。如果這兩個 DTO 放在不同的檔案且互相 import,就會碰上模組載入的「時間差」。

我們可以想像一下這個載入過程:假設系統先讀取 post.dto.ts,在真正建立 PostDto 之前,它必須先去解析引入的 author.dto.ts。這時尷尬的情況發生了,author.dto.ts 裡的裝飾器回頭想抓取 PostDto 的型別,但 PostDto 根本還沒完成初始化,導致 type: PostDto 實際上拿到的值是 undefined。

在目前的編譯環境中,遇到這種無法確認的型別,會先讓它退化成基礎的 Object。當 @ApiProperty() 接收到 undefined 後,便沿用這份退化的資訊,最後默默產出一個空泛的 { "type": "object" }。

等到載入流程順利回到 post.dto.ts 時,因為 AuthorDto 已經完整建立,這時 author 欄位反而能如預期般產生正確的 $ref。從這裡可以發現,直接傳入 DTO 依然屬於「立即求值」,它不僅無法解決跨檔案循環載入的順序問題,在某些打包環境中,這類時間差甚至可能在系統啟動時直接引發錯誤。

排雷指南

使用延遲求值函式(lazy function)延後取得 class:

// post.dto.ts
export class PostDto {
  // ...
  @ApiProperty({ type: () => AuthorDto })
  author: AuthorDto;
}

// author.dto.ts
export class AuthorDto {
  // ...
  @ApiProperty({ type: () => PostDto })
  featuredPost: PostDto;
}

type: () => AuthorDto 讓 Swagger 在解析 schema 時才取得對應的 class,而不是直接使用裝飾器定義階段的型別資訊。這也是 NestJS 官方建議處理循環參照的方式。

循環參照的兩側都應使用延遲求值函式,避免其中一個方向仍然依賴模組載入順序。

總結

  1. 基本型別能直接推導:string、number、boolean、Date 等基本型別,以及一般的 DTO class,通常能透過執行期 metadata 取得足夠的型別資訊。
  2. 陣列必須補充元素型別:使用 type: [TagDto] 或 type: TagDto, isArray: true。Swagger 知道外層是 Array,不代表它知道裡面的 items 裝了什麼。
  3. enum 的值域與容器需分開描述:enum 負責定義合法值,isArray 負責宣告是否為陣列,而 enumName 則讓 schema 得以被重複參照。
  4. 聯合型別與多維陣列需直接使用 OpenAPI schema 語法:處理聯合型別應使用 oneOf,多維陣列則使用巢狀 items,不應過度期待單一 type 設定能還原複雜的資料形狀。
  5. 循環 DTO 應採用延遲求值函式:互相參照的兩側都寫成 type: () => Dto,確保文件的正確性不再取決於不可控的模組載入順序。

參考資料


上一篇
Day 28|同床異夢的 DTO:TypeScript、Validation 與 Swagger 的平行世界
下一篇
Day 30|絕地求生最終章:排雷心法&完賽總結
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言