今天我們把焦點放回一個單純的欄位:文章標題(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());
title 欄位Request Payload: {}
結果: 200 OK(符合預期)
{
"id": "42"
}
不傳 title 代表不打算更新標題,驗證順利通過。
Request Payload: {"title": ""}
結果: 400 Bad Request(符合預期)
{
"message": ["title should not be empty"],
"error": "Bad Request",
"statusCode": 400
}
欄位有傳但為空字串,如期被 @IsNotEmpty() 擋下。
nullRequest 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() 便會讓這個欄位跳過其他驗證:

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