iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Modern Web

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

Day 15|空值的考驗:明明加了 @IsNotEmpty(),為什麼 null 還是能過?拆解 @IsOptional() 條件驗證的機制

  • 分享至 

  • xImage
  •  

今天我們把焦點放回一個單純的欄位:文章標題(title)。建立文章時,我們規定標題為必填欄位,但在更新文章(PATCH)時,使用者可能只想更換內文,並不打算修改標題。於是我們將 title 設為選填,並同時加上 @IsOptional()、@IsNotEmpty() 與 @IsString() 驗證。

初步測試結果都符合預期,不傳 title 可以通過,傳入空字串或數字會被擋下來。直到測試 { "title": null } 時,沒想到 API 不僅完全沒有報錯,還一路暢行到了 Controller。

明明 DTO 上已經掛了 @IsNotEmpty(),為什麼 null 還能輕鬆過關?這篇文章就來拆解這個藏在選填背後的空值陷阱。

問題怎麼發生?

假設我們正在開發一支更新文章的 API,對於 title 欄位有以下預期行為:

  • 欄位未提供:代表這次不更新標題,直接保留原值。
  • 欄位有提供內容:必須是非空字串。
  • 欄位傳入 null:視為無效請求並直接拒絕。

基於這個需求,我們定義出這樣的 DTO:

import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

export class UpdatePostDto {
  /* ...省略其他屬性... */
  @IsOptional()
  @IsString()
  @IsNotEmpty()
  title?: string;
}

Controller 則直接回傳收到的內容,方便觀察驗證後的請求資料:

@Patch(':id')
update(
  @Param('id') id: string,
  @Body() body: UpdatePostDto,
) {
  return { id, ...body };
}

專案的 main.ts 也已經啟用了全域 ValidationPipe:

app.useGlobalPipes(new ValidationPipe());

測試情境 1:未帶 title 欄位

Request Payload: {}
結果: 200 OK(符合預期)

{
  "id": "42"
}

不傳 title 代表不打算更新標題,驗證順利通過。

測試情境 2:傳入空字串

Request Payload: {"title": ""}
結果: 400 Bad Request(符合預期)

{
  "message": ["title should not be empty"],
  "error": "Bad Request",
  "statusCode": 400
}

欄位有傳但為空字串,如期被 @IsNotEmpty() 擋下。

測試情境 3:傳入 null

Request Payload: {"title": null}
結果: 200 OK(出現漏洞)

{
  "id": "42",
  "title": null
}

null 竟然直接繞過了 @IsString() 與 @IsNotEmpty() 的雙重防線,一路順利闖進 Controller!

如果後續邏輯直接拿這個值存入資料庫,輕則觸發 NOT NULL 約束拋出 500 錯誤,重則直接把無效資料寫進系統。

根因:@IsOptional() 不只代表「欄位可以省略」

問題的核心,在於 @IsOptional() 判定「缺值」的方式,跟我們直覺想的「前端沒傳這個 key」存在落差。

在 class-validator(以 0.14.4 為例)中,@IsOptional() 本質上是一個條件式驗證器。它的核心判斷邏輯可簡化如下:

object[propertyName] !== null && object[propertyName] !== undefined;

換句話說,只有當該欄位的值既不是 null 也不是 undefined 時,條件才算成立,同欄位上的其他驗證器才會被觸發。

一旦收到 null 或 undefined,@IsOptional() 便會讓這個欄位跳過其他驗證:

https://ithelp.ithome.com.tw/upload/images/20260929/201843061P7IKHcauS.png

所以問題並不是 @IsNotEmpty() 放行了 null。事實上,如果單獨使用 @IsNotEmpty() 時,它可以正確地擋下空字串、null 和 undefined。但在這個例子中,它連登場的機會都沒有,就被 @IsOptional() 直接請出場了。

我們把各種輸入情境攤開比較,行為差異就很清晰:

輸入值 條件判定 (≠ null & undefined) 驗證機制 API 回傳結果
undefined(省略欄位) false 跳過後續驗證 200 OK
null false 跳過後續驗證 200 OK(出現漏洞)
"" true 觸發驗證,被 @IsNotEmpty() 擋下 400 Bad Request
123 true 觸發驗證,被 @IsString() 擋下 400 Bad Request
合法字串 true 觸發驗證,通過所有規則 200 OK

排雷指南:使用 @ValidateIf() 自訂驗證條件

回到我們的需求目標:欄位允許省略,但不接受 null。

既然 @IsOptional() 會同時放行 null 與 undefined,我們就不該繼續使用它,而是要改用 @ValidateIf() 來自訂驗證條件:

import { IsNotEmpty, IsString, ValidateIf } from 'class-validator';

export class UpdatePostDto {
  @ValidateIf((_object, value) => value !== undefined)
  @IsString()
  @IsNotEmpty()
  title?: string;
}

@ValidateIf() 的機制非常直觀:當 callback 回傳 false 時,會跳過該欄位的驗證;回傳 true 時,則正常執行其他驗證器。

這樣寫之後,過濾條件只會排除 undefined:

輸入值 判斷結果 (!== undefined) 驗證機制 API 回傳結果
undefined(省略欄位) false 跳過後續驗證 200 OK
null true 觸發驗證,被 @IsString() 擋下 400 Bad Request
"" true 觸發驗證,被 @IsNotEmpty() 擋下 400 Bad Request
123 true 觸發驗證,被 @IsString() 擋下 400 Bad Request
合法字串 true 觸發驗證,通過所有規則 200 OK

我們再次發送帶有 null 的請求到路由:

curl -i -X PATCH http://localhost:3000/posts/42 \
  -H 'Content-Type: application/json' \
  -d '{"title":null}'

這次 API 順利擋下非法請求並回傳 400 Bad Request。而當我們不傳 title 時,選填的行為依然正常運作。

透過 @ValidateIf(),我們精準地將「跳過驗證」的條件限定在 undefined,既保留了選填的彈性,又成功攔截了非法的 null。

總結

  1. @IsOptional() 同時放行 null 與 undefined:只要欄位值為這兩者,同欄位的其餘驗證規則(如 @IsString()、@IsNotEmpty())都會被直接跳過。
  2. 使用 @ValidateIf() 控制驗證時機:若 API 契約是「允許省略,但拒絕 null」,應改用 @ValidateIf((_o, v) => v !== undefined) 替代 @IsOptional(),確保只有省略欄位時能跳過檢查。

參考資料


上一篇
Day 14|形同虛設的內層防線:為什麼巢狀 DTO 驗證不生效?
下一篇
Day 16|Boolean 的謊言:為什麼查詢參數寫了布林值,判斷卻永遠出錯?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言