昨天我們談到 DTO 欄位為何會從 Swagger 文件裡消失。今天來探討另一種狀況:欄位有順利出現、文件也能正常產生,但 schema 描述的型別,卻與程式實際接收的型別有落差。
這類問題通常不會引發系統錯誤,Swagger UI 也能正常顯示,但產出的 API 規格卻與實際資料結構不一致。今天我們就來拆解這類Swagger 有抓到部分型別,卻沒有完整還原的問題。
@ApiProperty() 能推導出哪些型別?NestJS Swagger 可以透過 TypeScript 編譯時產生的 metadata,取得部分型別資訊,再用來建立 OpenAPI Schema。不過,TypeScript 的型別資訊並不會在編譯後完整保留下來:

使用 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,原本的元素型別與巢狀結構等細節就無法直接得知。
這就導致了以下四種常見的情況:
enum 陣列被描述成單一值object,二維陣列少一層$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" }
}

在這種情況下,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"]
}

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" }
}
}

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,結構反而會更清晰。
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;
}

兩個欄位都已經透過 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 官方建議處理循環參照的方式。
循環參照的兩側都應使用延遲求值函式,避免其中一個方向仍然依賴模組載入順序。
string、number、boolean、Date 等基本型別,以及一般的 DTO class,通常能透過執行期 metadata 取得足夠的型別資訊。type: [TagDto] 或 type: TagDto, isArray: true。Swagger 知道外層是 Array,不代表它知道裡面的 items 裝了什麼。enum 的值域與容器需分開描述:enum 負責定義合法值,isArray 負責宣告是否為陣列,而 enumName 則讓 schema 得以被重複參照。oneOf,多維陣列則使用巢狀 items,不應過度期待單一 type 設定能還原複雜的資料形狀。type: () => Dto,確保文件的正確性不再取決於不可控的模組載入順序。