接續前兩天對 DTO 類別實例(Day 12)與 ValidationPipe 運作流程(Day 13)的拆解,今天我們進入 API 請求解析中極為常見、也極易出現防禦缺口的資料結構——巢狀 DTO。
當 API 需要接收包含子物件或陣列的資料時,許多開發者會在外層欄位掛上 @ValidateNested(),以為這樣就能自動觸發內層 DTO 的驗證規則。然而現實情況卻是:在 NestJS ValidationPipe 的預設驗證設定下,即使內層資料格式完全不合規,系統卻依然回傳 201 Created 順利通關。
這種「看似設了防線,內部驗證卻靜默失效」的地雷極容易被忽略。今天我們就來拆解為什麼單靠 @ValidateNested() 還不夠,以及如何正確啟用巢狀 DTO 的驗證機制。
假設我們今天要做一個建立文章的 API,這篇文章除了基本的標題與內容外,還會夾帶一個巢狀的 postMeta 物件來存放 SEO 資訊。
我們先為內層的 SEO 資料定義了 PostMetaDto:
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class PostMetaDto {
@IsString()
@IsNotEmpty()
@MaxLength(60)
seoTitle: string;
@IsString()
@MaxLength(160)
seoDescription: string;
}
接著,將這個內層結構嵌入外層的 CreatePostDto,並掛上 @ValidateNested() 告知 NestJS 進行向下驗證:
import { IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { PostMetaDto } from './post-meta.dto';
export class CreatePostDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
content: string;
// 明確告訴守衛,這個屬性裡有其他結構要驗證
@ValidateNested()
postMeta: PostMetaDto;
}
在控制器中定義路由:
@Controller('posts')
export class PostsController {
@Post()
createPost(@Body() body: CreatePostDto) {
return body;
}
}
最後,我們在 main.ts 掛載全域 ValidationPipe,並開啟了 transform: true:
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
內外層都設妥了驗證規則,這防線看起來很完美,對吧?
現在,我們故意送一筆格式錯誤的請求資料到這支 API:
{
"title": "My first post",
"content": "Hello NestJS",
"postMeta": {
"seoTitle": "",
"seoDescription": 12345
}
}
在這個請求裡,seoTitle 與 seoDescription 都是不該被通過的值。你可能正期待送出請求時拋出 400 Bad Request 。然而,API 卻回傳了 201 Created,這包充滿錯誤的資料,就這樣毫無阻擋地進到了控制器。
也就是說,我們掛在外層的 @ValidateNested() 沒有啟動任何實質的檢查,那些寫在內層的 @IsString() 等裝飾器,全部變成了無效的擺飾。
還記得我們在 Day 12 提到的機場行李託運比喻嗎?
後端的分揀員(ValidationPipe)必須依賴行李上的實體標籤(class 內部的 metadata)才能核對驗證規則。當一個 HTTP 請求進入系統時,請求資料的本質只是普通 JSON,轉換成 JavaScript 後也僅是 plain object。
我們可以延續行李託運的邏輯,來看看這次的巢狀結構發生了什麼事:
ValidationPipe 透過 Controller 參數型別(metatype)得知預期的 class,因此能順利將頂層物件轉換為 CreatePostDto 的實例,並核對 title 與 content 的規則。@ValidateNested(),就像是一張提醒貼紙,對分揀員喊著:「請記得打開 postMeta 這個子箱子檢查裡面的東西!」然而,當分揀員打開行李拿出 postMeta 時,卻發現這只是一個沒有任何標籤的普通物件(plain object)。

原因在於:在資料解析階段,我們沒有指定內層物件的轉型規則。class-transformer 預設不會自動將內層普通物件轉換為 PostMetaDto 的類別實例(instance)。
既然 postMeta 只是普通的 JavaScript 物件而非 PostMetaDto 實例,class-validator 就無法從其 metadata 儲存區查出對應的規則。分揀員查不到規則,最後只能將其當作一般物件直接放行,導致內層的驗證機制完全靜默失效。
@Type 實例化內層物件既然根因在於內層資料只是 plain object,解法就是告知 class-transformer 在解析時,確實將其轉化為 PostMetaDto 的類別實例。
負責這項轉型工作的,就是 class-transformer 提供的 @Type() 裝飾器。我們只需在 CreatePostDto 中補上設定:
import { Type } from 'class-transformer'; // 引入 Type
import { IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { PostMetaDto } from './post-meta.dto';
export class CreatePostDto {
/* ...省略其他屬性... */
@ValidateNested()
@Type(() => PostMetaDto) // 指定內層應轉換的類別實例
postMeta: PostMetaDto;
}
@Type(() => PostMetaDto) 的作用非常明確,它告訴轉換機制:「當解析到 postMeta 這個欄位時,請將傳入的 plain object 實例化為 PostMetaDto 的類別實例。」
當 PostMetaDto 的實例被建立後,掛載於其上的驗證規則才會正式生效。這時 ValidationPipe 進行查驗,就能精準攔截格式錯誤的資料:
{
"message": [
"postMeta.seoTitle should not be empty",
"postMeta.seoDescription must be shorter than or equal to 160 characters",
"postMeta.seoDescription must be a string"
],
"error": "Bad Request",
"statusCode": 400
}
@Type() 會導致巢狀驗證靜默失效:內層資料若未轉換成 DTO 實例,而仍是 plain object,class-validator 就無法讀取該 DTO 裝飾器所登記的驗證規則,系統會直接跳過內層驗證,不會拋出任何錯誤。@ValidateNested() 不負責轉型:它僅通知驗證器「向下深入檢查」,必須搭配 @Type() 才能確保內層物件被正確實例化。@ValidateNested() 與 @Type() 必須搭配使用:前者負責告知驗證器向下檢查,後者負責將內層資料實例化為指定的 DTO 類別。無論處理巢狀物件或物件陣列,兩者都是驗證 HTTP 請求資料時的標準組合。