昨天我們聊到了查詢參數中「布林值其實只是字串」的坑,揭穿了 TypeScript 型別並無法改變 HTTP 傳輸層的真實資料形狀。今天,我們要接著看另一個延伸問題:查詢參數的陣列轉型陷阱。
在開發 API 時,初學者很容易把 TypeScript 當成許願池,以為只要在 DTO 寫下 tags: string[] 的心願,NestJS 就會自動把查詢參數包成陣列。但現實是,在常見的預設 Query Parser(查詢參數解析器)行為下,當前端只傳了一個標籤時,傳進來的會是一條普通字串,隨後就被 NestJS 以「型別不符」為由無情地拋出 400 Bad Request。
今天這篇文章,就帶你徹底拆解這個查詢參數陣列地雷!
假設我們正在開發一支文章列表 API,需要支援「多重標籤」過濾功能。前後端約定好,當要查詢多個標籤時,使用「重複 Key」的方式來傳遞:
GET /posts?tags=tech&tags=life
為了驗證這個參數,我們宣告了對應的 DTO:
import { IsArray, IsOptional, IsString } from 'class-validator';
export class PostsQueryDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
}
接著在 Controller 接收參數並掛上 ValidationPipe:
@Get()
getPosts(
@Query(new ValidationPipe({ transform: true })) query: PostsQueryDto,
) {
return query.tags;
}
當前端查詢多個標籤(?tags=tech&tags=life)時,一切運作正常。但如果某次查詢,前端只傳了一個標籤:
GET /posts?tags=tech
直覺上會以為:
「既然宣告了 tags 是 string[],有傳值的話,NestJS 應該會自動把它包成 ['tech'] 吧?」
結果一送出請求,馬上被打臉:
{
"message": [
"tags must be an array"
],
"error": "Bad Request",
"statusCode": 400
}
當 HTTP 請求抵達後端時,NestJS 底層的框架(如 Express 或 Fastify)會先透過 Query Parser 將 URL 後面的 ?tags=... 字串轉換成 JavaScript 物件,接著才會交給我們的 Controller。
讓我們先褪去 DTO 的外衣與驗證機制的保護,直接看看 Query Parser 解析後,Controller 實際取得的 tags 是什麼:
@Get()
getPosts(@Query('tags') tags: string[]) {
return {
tags,
isArray: Array.isArray(tags)
};
}
嘗試幾種不同的呼叫方式,你會發現 Controller 實際接收到的資料形狀截然不同:
| 呼叫方式 | 查詢參數範例 | NestJS 接收到的 tags 型別與數值 |
|---|---|---|
| 單一值 | ?tags=tech |
'tech' (型別:string) |
| 重複 Key | ?tags=tech&tags=life |
['tech', 'life'] (型別:string[]) |
| 逗號分隔 | ?tags=tech,life |
'tech,life' (型別:string) |
從表格可以清楚看出,在常見的預設 Query Parser 行為下,會根據「Key 在 URL 出現的次數」來決定資料形狀:出現一次解析為字串,出現多次才組合成陣列。
這也是為什麼只傳單一值時會破功——TypeScript 的型別宣告只代表你的設計意圖,無法在執行期改變 Query Parser 解析後的資料形狀。
明白這點後,我們該如何在後端將資料形狀強制收斂成標準陣列?
既然查詢參數的最終形狀受限於 URL 寫法與底層解析邏輯,解決這道難題的關鍵,就在於:必須主動進行資料的「正規化」,抹平單一值與多重值的差異。
實務上,我們可以根據團隊約定的 API 契約,以及參數的接收與驗證方式,選擇以下兩種常見解法:
@Transform()HTML 表單中,如果有多個同名欄位,或使用 <select multiple> 選取多個值,送出時可能會形成 ?tags=a&tags=b 這種「重複 Key」格式。若你的 API 採用這種契約,且需要依賴 DTO 進行複雜的跨欄位驗證,你可以利用 @Transform() 把「可能是單一字串」的情況收斂為陣列:
import { Transform } from 'class-transformer';
import { IsArray, IsOptional, IsString } from 'class-validator';
export class PostsQueryDto {
@Transform(({ value }) => {
if (value === undefined) return undefined;
return Array.isArray(value) ? value : [value];
})
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
}
這樣一來,無論進來的是單一字串 'tech',還是陣列 ['tech', 'life'],最後交給 @IsArray() 驗證並進入 Controller 的邏輯時,必定是標準的陣列型別。
ParseArrayPipe如果你不需要透過 DTO 進行複雜的驗證,或者團隊約定使用「逗號分隔」(?tags=a,b)來縮短網址,那麼 NestJS 內建的 ParseArrayPipe 會是更俐落的選擇。
最棒的是,ParseArrayPipe 不僅能完美處理逗號分隔,即使面對單一值或重複 Key 的情境,它也能順利轉型:
import { Controller, Get, Query, ParseArrayPipe } from '@nestjs/common';
@Get()
getPosts(
@Query('tags', new ParseArrayPipe({ items: String, separator: ',', optional: true }))
tags?: string[],
) {
return tags;
}
tags: string[] 並不會在執行期幫 HTTP 請求自動轉型。URL 的查詢參數被解析成什麼樣子,後端實際收到的資料就是什麼。@Transform();若是單純的參數提取,則可以直接套用內建的 ParseArrayPipe。<select> element