解決了前面「路由與請求進入」階段 CORS 與路由匹配的難題,今天我們正式進入「請求解析」的新階段。
當後端準備接收這些請求時,身為工程師要注意的鐵律就是:「永遠不要相信前端傳來的資料」。為了不讓惡意的請求資料輕易突破防線、甚至污染我們珍貴的資料庫,對資料進行嚴格的欄位驗證,是守護後端的第一步。
在 NestJS 中,我們通常會派出 ValidationPipe 這位盡責的守衛,搭配 DTO 在最前線把關。但很多剛從純 TypeScript 或者是 Express 轉戰 NestJS 的工程師,可能會撞上這個靈異現象——明明掛了驗證,那些亂填的髒資料卻如入無人之境,毫無阻攔地進來。
今天,我們就要來拆解這個 NestJS 的經典陷阱——DTO 為什麼不能用 interface 和 type?
在開發 API 時,我們習慣用 TypeScript 的 interface 或是 type 來宣告結構:
// 地雷:使用 interface 作為 DTO
interface CreatePostDto {
title: string;
content: string;
authorId: number;
}
@Controller('posts')
export class PostsController {
@Post()
@UsePipes(new ValidationPipe())
createPost(@Body() body: CreatePostDto) {
return body;
}
}
此時,我們刻意送出一個欄位型別錯誤的請求:
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{
"title": 12345,
"content": "文章內容",
"authorId": 1
}'
你期待守衛大喊 400 Bad Request 把型別不符的請求擋下,結果 API 卻回傳了 201 Created,把這些毒蘋果原封不動地送進了 Controller。
要了解這個坑,我們必須理解 TypeScript 的天生限制與型別擦除(Type Erasure)特性:interface 與 type 只存在於編譯階段,在執行期(Runtime)它們會徹底消失。
如果把這個運作過程比喻成機場行李託運,來看看這兩者的根本差異:
情境 A:使用 interface / type |
情境 B:使用 class |
|
|---|---|---|
| 編譯期 | 行李資訊只顯示在螢幕上(VS Code 型別提示) | 行李資訊會印出成實體託運標籤掛在行李箱上(class 會保留到執行期) |
| 執行期 | 分揀員看到的是光溜溜的箱子,沒有任何標籤,只能放行 | 分揀員看一眼標籤,立刻核對規則,不合規定當場攔截 |
ValidationPipe |
全部放行,驗證形同虛設 | 確實攔截,錯誤直接回傳 400 Bad Request |

NestJS 的 ValidationPipe 在執行期準備進行驗證時,第一步必須先知道「這個路由參數預期要被轉換成什麼類別」。然而,由於 TypeScript 在編譯成 JavaScript 後會進行型別擦除,interface 與 type 在執行期會徹底消失,這個參數只會被記錄為通用的 Object。
既然拿不到具體的類別資訊,ValidationPipe 就根本無從得知要向誰索取驗證規則,自然只能將其視為普通的 JSON 物件直接放行。
class想讓分揀員(ValidationPipe)在執行期有標籤可核對,我們必須將 DTO 改寫成實體的 class,並用裝飾器把驗證規則烙印在標籤上:
import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
export class CreatePostDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
content: string;
@IsInt()
@Min(1)
authorId: number;
}
這時候再次送出惡意請求,分揀員(ValidationPipe)就能順利辨識標籤,並成功擋下:
{
"message": [
"title must be a string"
],
"error": "Bad Request",
"statusCode": 400
}
⚠️ 注意:
當 NestJS 的功能需要在執行期取得型別資訊時,不能只依賴interface或type。例如使用class-validator搭配ValidationPipe時,DTO 通常需要使用實際存在於執行期的class。
你以為只要把所有 DTO 都換成 class 就萬無一失了嗎?
實務上,當你的資料結構變複雜,例如一個建立文章的 DTO 裡面,還包了另一個作者資訊的 DTO(物件或陣列)時,驗證機制還是可能再次斷鏈:
export class CreatePostDto {
@IsString()
title: string;
// 地雷:就算 AuthorDto 也是個 class,這樣寫內部的欄位驗證依然會被跳過!
author: AuthorDto;
}
這個進階的「巢狀 DTO 驗證遺失」是極為經典的陷阱。用剛才的比喻來說,這是因為分揀員預設只會檢查大行李箱外的標籤,完全不會自動打開箱子去檢查裡面的小包裹。
我們會在後續的連載中(Day 14),專門用一天的篇幅來帶大家拆解這個地雷!
interface 和 type 不是行李上的實體標籤:它們屬於編譯期的型別資訊,編譯後完全消失,ValidationPipe 在執行期根本找不到任何標籤可以核對。class:任何需要被 ValidationPipe 驗證的結構,都必須是帶有 class-validator 裝飾器的具體 class。class,分揀員預設只檢查外層標籤,裡面的小包裹需要另外告知他打開來看。