iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

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

Day 12|失靈的守衛:為什麼 DTO 不能用 interface 和 type?

  • 分享至 

  • xImage
  •  

解決了前面「路由與請求進入」階段 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

https://ithelp.ithome.com.tw/upload/images/20260926/20184306C4DoCbdH3B.jpg

NestJS 的 ValidationPipe 在執行期準備進行驗證時,第一步必須先知道「這個路由參數預期要被轉換成什麼類別」。然而,由於 TypeScript 在編譯成 JavaScript 後會進行型別擦除,interface 與 type 在執行期會徹底消失,這個參數只會被記錄為通用的 Object。

既然拿不到具體的類別資訊,ValidationPipe 就根本無從得知要向誰索取驗證規則,自然只能將其視為普通的 JSON 物件直接放行。

排雷指南:賦予血肉的 DTO 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 巢狀時,守衛又偷懶了?

你以為只要把所有 DTO 都換成 class 就萬無一失了嗎?

實務上,當你的資料結構變複雜,例如一個建立文章的 DTO 裡面,還包了另一個作者資訊的 DTO(物件或陣列)時,驗證機制還是可能再次斷鏈:

export class CreatePostDto {
  @IsString()
  title: string;

  // 地雷:就算 AuthorDto 也是個 class,這樣寫內部的欄位驗證依然會被跳過!
  author: AuthorDto; 
}

這個進階的「巢狀 DTO 驗證遺失」是極為經典的陷阱。用剛才的比喻來說,這是因為分揀員預設只會檢查大行李箱外的標籤,完全不會自動打開箱子去檢查裡面的小包裹。

我們會在後續的連載中(Day 14),專門用一天的篇幅來帶大家拆解這個地雷!

總結

  1. interface 和 type 不是行李上的實體標籤:它們屬於編譯期的型別資訊,編譯後完全消失,ValidationPipe 在執行期根本找不到任何標籤可以核對。
  2. DTO 必須是 class:任何需要被 ValidationPipe 驗證的結構,都必須是帶有 class-validator 裝飾器的具體 class。
  3. 巢狀 DTO 的驗證不會自動向內穿透:就算內層也是 class,分揀員預設只檢查外層標籤,裡面的小包裹需要另外告知他打開來看。

參考資源


上一篇
Day 11|跨不過的牆:CORS 設定明明開了,前端卻還是拿不到回應?
下一篇
Day 13|消失的 Class 實例:transform: false 到底保留了什麼,又丟掉了什麼?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言