在 NestJS 裡,我們經常使用同一份 DTO 同時處理 TypeScript 型別、驗證與 Swagger 文件。DTO 明明只有一份,看起來三者應該都會得到相同的資訊,但事情沒有這麼單純。
當 Swagger metadata 與 Runtime Validation 對 DTO 的理解不同步時,你可能會遇到一些乍看非常矛盾的現象:
object。要理解這些看似矛盾的結果,得先看看同一份 DTO 在不同機制裡,究竟留下了哪些資訊。
問題的關鍵在於:同一個 DTO,TypeScript、Validation 與 OpenAPI 所依賴的資訊並不相同。
可以把它粗略分成三個面向:

| 面向 | 主要負責什麼 | 資訊來源 |
|---|---|---|
| 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 靜態檢查
}
三套機制各自只關心自己那一部分:
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 |
這四種情況看起來各不相同,但本質上都是同一件事:不同機制取得的資訊不同,因此最後產生的結果也可能不同。
假設我們有一個 PostFieldsDto:
class PostFieldsDto {
title: string;
content: string;
tags: string[];
}
這個範例沒有啟用 Swagger CLI Plugin。
從 TypeScript 的角度來看,這個 class 很明確地包含 title、content 和 tags 三個欄位。
但實際產生 OpenAPI 文件後,這三個欄位卻沒有出現在 schema 中。在 Swagger UI 裡看到的 model,也只剩下一個空的結構。

問題不在 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 結構是一片空白。

即使嘗試指定巢狀型別,也無法直接把 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.
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。

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 也不會自動補齊。
@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,欄位直接標紅:

Swagger UI 會直接阻止執行,因此 request 根本沒有送到後端;但用 curl 傳送完全相同的值時,API 卻能正常通過驗證並回傳 200 OK。
這條錯誤訊息最容易誤導人的地方在於:它印出來的那條正則,看起來和 DTO 裡的 @Matches() 完全相同。
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 字串。
@ApiProperty() 解決的是 OpenAPI metadata:在沒有啟用 Swagger CLI Plugin 的情況下,單純宣告 title: string 並不足以讓 Swagger 知道這個欄位存在,必須透過 @ApiProperty() 等方式提供 OpenAPI metadata。interface 只存在於型別系統中,編譯成 JavaScript 後就會被移除,因此 Swagger 無法只靠 runtime reflection 還原它的內部結構。需要被 OpenAPI 重複引用或描述巢狀 schema 時,使用 class 會更可靠。Partial<T> 與 PartialType() 解決的是不同層次的問題:TypeScript 的 Partial<T> 只改變編譯期型別,執行期不會留下新的 DTO class;@nestjs/swagger 的 PartialType() 則會建立真正的 class,並延續原始 DTO 的 Swagger metadata,再將欄位標記為可選。@Matches() 驗證實際請求,pattern 描述 OpenAPI 規格。即使 CLI Plugin 能協助同步,仍應檢查實際產生的 OpenAPI 文件,並留意 @nestjs/swagger 11.4.0 以前的正則轉換問題。Partial<Type>)