iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Modern Web

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

Day 28|同床異夢的 DTO:TypeScript、Validation 與 Swagger 的平行世界

  • 分享至 

  • xImage
  •  

在 NestJS 裡,我們經常使用同一份 DTO 同時處理 TypeScript 型別、驗證與 Swagger 文件。DTO 明明只有一份,看起來三者應該都會得到相同的資訊,但事情沒有這麼單純。

當 Swagger metadata 與 Runtime Validation 對 DTO 的理解不同步時,你可能會遇到一些乍看非常矛盾的現象:

  • 程式碼裡明明存在的欄位,Swagger 文件卻看不到。
  • TypeScript 認得的資料結構,Swagger 只能把它描述成普通 object。
  • 後端認為合法的輸入,Swagger UI 卻可能在請求送出以前就拒絕它。

要理解這些看似矛盾的結果,得先看看同一份 DTO 在不同機制裡,究竟留下了哪些資訊。

底層解析:TypeScript、Validation 與 OpenAPI 各自看到什麼?

問題的關鍵在於:同一個 DTO,TypeScript、Validation 與 OpenAPI 所依賴的資訊並不相同。

可以把它粗略分成三個面向:

https://ithelp.ithome.com.tw/upload/images/20261012/20184306povkcMeaFf.png

面向 主要負責什麼 資訊來源
TypeScript 編譯期間判斷程式碼中的型別是否合理 string、number、interface、generic 等 TypeScript 型別宣告
Validation 請求進入 Controller 前是否通過驗證 @IsString()、@Matches() 等 class-validator metadata
OpenAPI API 文件中有哪些欄位、型別與限制 @ApiProperty()、Swagger plugin、有限的 runtime reflection metadata

雖然這三個面向描述的可能是同一個欄位,但它們取得資訊的方式並不相同。

以這個 DTO 欄位為例:

export class FindPostsQueryDto {
  @ApiProperty({ pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' }) // 影響 OpenAPI Schema
  @Matches(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)                  // 影響 Runtime Validation
  publishedAfter: string;                                    // 影響 TypeScript 靜態檢查
}

三套機制各自只關心自己那一部分:

  • TypeScript 只知道它是 string,對後端驗證與 Swagger 文件完全無感。
  • @Matches() 只會讓 ValidationPipe 在執行期檢查 Request,不會自動幫 Swagger 生成 pattern 規則。
  • @ApiProperty() 只會改變 Swagger UI 的文件描述與前端輸入框限制,完全不會幫後端增加任何驗證。

💡 補充說明:
如果專案啟用了 NestJS Swagger CLI Plugin,它可以在編譯階段替這些機制做「部分同步」,例如從 DTO 型別或部分 class-validator 裝飾器產生 OpenAPI metadata,但這仍然不代表三者變成同一套機制。

因此,當 Swagger UI 與實際 API 行為不一致時,問題不一定出在 DTO 本身。更重要的是先確認 TypeScript、Validation 與 OpenAPI 各自拿到了哪些資訊。

前面提到的差異,會在實際開發中以不同形式出現。接下來會依序看四種常見情況:

現象 發生落差的地方
DTO 已經宣告欄位,但 Swagger 沒有顯示 TypeScript declaration ≠ OpenAPI metadata
巢狀結構使用 interface,Swagger 只看得到 object TypeScript type information ≠ runtime 可取得的型別資訊
使用 Partial<T> 後,Swagger 沒有產生 request body TypeScript mapped type ≠ runtime DTO class
@Matches() 驗證合法,但 Swagger UI 卻擋下輸入 Runtime Validation ≠ OpenAPI pattern

這四種情況看起來各不相同,但本質上都是同一件事:不同機制取得的資訊不同,因此最後產生的結果也可能不同。

現象一:明明寫了欄位,Swagger 卻視而不見

問題怎麼發生?

假設我們有一個 PostFieldsDto:

class PostFieldsDto {
  title: string;
  content: string;
  tags: string[];
}

這個範例沒有啟用 Swagger CLI Plugin。

從 TypeScript 的角度來看,這個 class 很明確地包含 title、content 和 tags 三個欄位。

但實際產生 OpenAPI 文件後,這三個欄位卻沒有出現在 schema 中。在 Swagger UI 裡看到的 model,也只剩下一個空的結構。

https://ithelp.ithome.com.tw/upload/images/20261012/20184306Ndum7sXn8z.png

根因:Swagger 無法只靠 TypeScript 宣告取得欄位資訊

問題不在 DTO 沒有定義欄位,而是 Swagger 無法只靠 TypeScript 的屬性宣告還原一個 class 包含哪些屬性。

TypeScript 編譯成 JavaScript 後,像 title: string、content: string 這類型別宣告本身不會完整保留下來。雖然 runtime reflection 可以取得部分型別資訊,但無法單靠這些資訊列出 class 中所有宣告過的欄位。

因此,在沒有啟用 Swagger CLI Plugin 的情況下,需要透過 @ApiProperty() 等方式提供 Swagger 建立 OpenAPI schema 所需的 metadata。

排雷指南

幫欄位補上 @ApiProperty():

class PostFieldsDto {
  @ApiProperty({ description: '文章標題' })
  title: string;

  @ApiProperty({ description: '文章內容' })
  content: string;

  @ApiProperty({ type: [String], description: '文章標籤' })
  tags: string[];
}

現象二:巢狀結構有出現,但裡面是個空殼

問題怎麼發生?

我們在 DTO 定義了一個用 interface 宣告的巢狀結構:

interface PublishOptions {
  notifyFollowers: boolean;
}

class PostFieldsDto {
  ...
  @ApiProperty()
  publishOptions: PublishOptions; // 點開 Swagger 裡面是 {}
}

改成 type alias 也一樣:

type PublishOptions = {
  notifyFollowers: boolean;
};

在 Swagger UI 中,publishOptions 確實出現了,但它的 Schema 結構是一片空白。

https://ithelp.ithome.com.tw/upload/images/20261012/20184306xC5rVjQaWX.png

即使嘗試指定巢狀型別,也無法直接把 interface 或 type alias 當成 Swagger 的 type:

class PostFieldsDto {
  @ApiProperty({ type: () => PublishOptions })
  publishOptions: PublishOptions;
}

這段程式碼在 TypeScript 編譯階段就會出錯:

'PublishOptions' only refers to a type, but is being used as a value here.

根因:Interface 與 type alias 在執行期都不存在

TypeScript 中的 interface 與 type alias 只存在於型別系統中,編譯成 JavaScript 時會被移除。

當 Swagger 嘗試在執行期利用反射讀取 publishOptions 的型別時,它拿到的只剩下「它是一個 JavaScript 的 Object」,至於這個 Object 下面有哪些屬性、各自是什麼型別,所有的資訊在編譯當下就已經灰飛煙滅了。所以 schema 就只能留白,呈現為一個最基礎的空物件 {}。

排雷指南

改用 class 定義巢狀 DTO,並透過 @ApiProperty() 提供 Swagger 所需的欄位資訊:

export class PublishOptionsDto {
  @ApiProperty({ description: '發布時是否通知追蹤者' })
  notifyFollowers: boolean;
}

class PostFieldsDto {
  ...
  @ApiProperty({ type: () => PublishOptionsDto })
  publishOptions: PublishOptionsDto;
}

現象三:用了 Partial<T>,Swagger 卻連 request body 都不顯示

問題怎麼發生?

在實作更新 API 時,我們預期只需要傳入要修改的欄位。既然 PostFieldsDto 已經定義好完整的欄位,很直覺會想到使用 TypeScript 的 Partial<T> 產生更新用型別:

export class PostFieldsDto {
  @ApiProperty({ description: '文章標題' })
  title: string;

  @ApiProperty({ description: '文章內容' })
  content: string;

  @ApiProperty({ type: [String], description: '文章標籤' })
  tags: string[];

  @ApiProperty({ type: () => PublishOptionsDto })
  publishOptions: PublishOptionsDto;
}

export type UpdatePostDto = Partial<PostFieldsDto>;
@Patch(':id')
update(@Param('id') id: string, @Body() body: UpdatePostDto) {
  return this.postsService.update(id, body);
}

程式碼可以正常編譯,實際送出 request 時,Controller 也確實能收到 JSON body。

但打開 Swagger UI,這支 PATCH 只剩下一個 id 路徑參數 —— 連可以貼 JSON 的欄位都沒有,你根本無法用 "Try it out" 送出任何 body。

https://ithelp.ithome.com.tw/upload/images/20261012/201843062TxIyRUFwa.png

根因:Partial<T> 只活在型別層,編譯後什麼都不剩

Partial<T> 是 TypeScript 的 mapped type,它的工作在型別檢查階段就結束了。編譯成 JavaScript 之後,這個型別不會留下任何痕跡。

於是 @Body() 的 reflected parameter type 會退化成 Object,Swagger 因此拿不到 PostFieldsDto 這個實際 class,也就無法沿用它的 schema metadata。在這個例子中,最後產出的 OpenAPI 甚至不會出現 requestBody。

關鍵差異在於:Partial<T> 只產生型別,不會產生新的 DTO class。

@nestjs/swagger 的 PartialType() 名字很像,做的卻是完全不同的事:

比較項目 TypeScript Partial<T> @nestjs/swagger 的 PartialType()
本質 TypeScript 的映射型別 會建立新 class 的函式
執行期是否存在 不存在,編譯後只剩 Object 型別資訊 存在,是實際的 JavaScript class
Swagger 取得的型別 無法對應回原始 DTO 可以取得產生的新 DTO class
OpenAPI metadata 不會繼承原始 DTO 的 schema 資訊 會沿用原始 DTO 的 Swagger metadata,並將欄位設為可選

排雷指南

更新 DTO 請用 @nestjs/swagger 的 PartialType(),而不是 TypeScript 的 Partial<T>:

import { PartialType } from '@nestjs/swagger';

export class UpdatePostDto extends PartialType(PostFieldsDto) {}

⚠️ 注意:

  • PartialType() 有多個同名版本,包含 @nestjs/mapped-types、@nestjs/graphql 與 @nestjs/swagger。如果要產生 OpenAPI schema,應從 @nestjs/swagger 匯入。
  • PartialType() 只能沿用原始 DTO 已有的 Swagger metadata。若原始 DTO 本身缺少對應的 OpenAPI metadata,產生出的 Update DTO 也不會自動補齊。

延伸陷阱:開了 Swagger CLI Plugin,@Matches() 自動生成的 pattern 反而擋下合法值

問題怎麼發生?

前面幾個問題,大多來自 Swagger 缺少必要的 metadata。接下來這個情況則不同,問題出在 CLI Plugin 自動產生的 OpenAPI pattern。

有些專案為了方便會啟用 Swagger CLI Plugin:

// nest-cli.json
{
  "compilerOptions": {
    "plugins": ["@nestjs/swagger"]
  }
}

當 Swagger CLI Plugin 啟用,而且 classValidatorShim: true(預設值)時,Plugin 會在編譯階段讀取部分 class-validator 裝飾器,其中 @Matches() 可以被轉成 OpenAPI 的 pattern。

這樣看起來很方便,驗證規則只需要寫在 @Matches(),CLI Plugin 就能同步產生 OpenAPI 的 pattern,不必維護兩份相同的規則。

export class FindPostsQueryDto {
  @Matches(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
  publishedAfter: string;
}

然後你在 Swagger UI 按下 "Try it out",填入一個完全合法的 2026-09-02 10:30,欄位直接標紅:

https://ithelp.ithome.com.tw/upload/images/20261012/2018430646KUDQB7Y2.png

Swagger UI 會直接阻止執行,因此 request 根本沒有送到後端;但用 curl 傳送完全相同的值時,API 卻能正常通過驗證並回傳 200 OK。

這條錯誤訊息最容易誤導人的地方在於:它印出來的那條正則,看起來和 DTO 裡的 @Matches() 完全相同。

根因:舊版 Plugin 把正則的斜線一起寫進了 pattern

問題出在 CLI Plugin 將 @Matches() 轉成 OpenAPI pattern 的過程。

在 @nestjs/swagger 11.4.0 以前,Plugin 會直接讀取正則字面量的 .text:

// dist/plugin/visitors/model-class.visitor.js(11.3.2)

factory.createPropertyAssignment(
  'pattern',
  createPrimitiveLiteral(factory, head(decoratorArguments).text),
);

例如 DTO 中寫的是:

@Matches(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
publishedAfter: string;

產生的 OpenAPI schema 卻會變成:

{
  "pattern": "/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$/"
}

但 OpenAPI 的 pattern 只需要正則內容本身,前後的 / 不應該包含在內。

當 Swagger UI 使用這個字串建立正則時,/ 會被視為規則的一部分:

new RegExp(
  '/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$/',
).test('2026-09-02 10:30');
// false

new RegExp(
  '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$',
).test('2026-09-02 10:30');
// true

因此,Swagger UI 實際驗證的 pattern 已經和後端 @Matches() 使用的正則不同,原本合法的值也可能因此被擋下。

這個問題在 @nestjs/swagger 11.4.0 修正。新版 Plugin 會先取出真正的 pattern 內容,不再把前後的 / 寫進 OpenAPI schema:

if (ts.isRegularExpressionLiteral(firstArg)) {
  const match = firstArg.text.match(/^\/(.*)\/([gimsuy]*)$/); // 剝掉斜線與 flags
  if (match) {
    patternText = match[1];
  }
} else if (ts.isStringLiteral(firstArg)) {
  patternText = firstArg.text;
}

if (patternText === undefined) {
  return [];
}

排雷指南

1. 升級到 @nestjs/swagger >= 11.4.0

這個問題已在 11.4.0 修正。若專案仍使用舊版本,優先升級,避免 CLI Plugin 將正則前後的 / 一起寫入 OpenAPI pattern。

2. 直接檢查產生的 OpenAPI 文件

Swagger UI 只是 OpenAPI 文件的呈現結果。遇到驗證行為異常時,可以直接查看 /api-json 中實際產生的 pattern:

curl -s localhost:3000/api-json \
  | jq '.paths["/posts"].get.parameters[0].schema.pattern'

正常結果應該類似:

^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$

如果看到:

/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/

代表前後的 / 也被寫進 pattern。

3. 共用 RegExp 常數時,明確提供 OpenAPI pattern

CLI Plugin 在編譯階段分析的是程式碼語法,而不是執行期變數的實際內容。因此,把正則抽成常數後:

@Matches(PUBLISHED_AFTER_REGEX)
publishedAfter: string;

Plugin 看到的是 PUBLISHED_AFTER_REGEX 這個識別字,無法像行內正則字面量一樣直接取得 pattern。

如果專案使用 @nestjs/swagger >= 11.4.0,可以直接共用同一個 RegExp 常數:

export const PUBLISHED_AFTER_REGEX =
  /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;

export const PUBLISHED_AFTER_EXAMPLE =
  '2026-09-02 10:30';

export class FindPostsQueryDto {
  @ApiProperty({
    pattern: PUBLISHED_AFTER_REGEX,
    example: PUBLISHED_AFTER_EXAMPLE,
  })
  @Matches(PUBLISHED_AFTER_REGEX)
  publishedAfter: string;
}

這樣後端驗證與 OpenAPI 文件可以共用同一個 RegExp,同時避免另外維護一份需要跳脫的 pattern 字串。

總結

  1. @ApiProperty() 解決的是 OpenAPI metadata:在沒有啟用 Swagger CLI Plugin 的情況下,單純宣告 title: string 並不足以讓 Swagger 知道這個欄位存在,必須透過 @ApiProperty() 等方式提供 OpenAPI metadata。
  2. 巢狀 DTO 優先使用 class:TypeScript 的 interface 只存在於型別系統中,編譯成 JavaScript 後就會被移除,因此 Swagger 無法只靠 runtime reflection 還原它的內部結構。需要被 OpenAPI 重複引用或描述巢狀 schema 時,使用 class 會更可靠。
  3. Partial<T> 與 PartialType() 解決的是不同層次的問題:TypeScript 的 Partial<T> 只改變編譯期型別,執行期不會留下新的 DTO class;@nestjs/swagger 的 PartialType() 則會建立真正的 class,並延續原始 DTO 的 Swagger metadata,再將欄位標記為可選。
  4. 後端驗證與 API 文件是兩套規則:@Matches() 驗證實際請求,pattern 描述 OpenAPI 規格。即使 CLI Plugin 能協助同步,仍應檢查實際產生的 OpenAPI 文件,並留意 @nestjs/swagger 11.4.0 以前的正則轉換問題。

參考資料


上一篇
Day 27|失控的序列化:為什麼回傳 QueryBuilder 會引發循環參照錯誤?
下一篇
Day 29|失準的 API 文件:為什麼有了裝飾器,Swagger 還是無法完整還原你的資料結構?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言