iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

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

Day 17|消失的陣列:當查詢參數只有一個值時,為何變成字串?

  • 分享至 

  • xImage
  •  

昨天我們聊到了查詢參數中「布林值其實只是字串」的坑,揭穿了 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 契約,以及參數的接收與驗證方式,選擇以下兩種常見解法:

解法一:在 DTO 層攔截 —— 使用 @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;
}

總結

  1. TypeScript 型別不是魔法:單純標註 tags: string[] 並不會在執行期幫 HTTP 請求自動轉型。URL 的查詢參數被解析成什麼樣子,後端實際收到的資料就是什麼。
  2. 防禦性正規化:把單一值與多重值的資料形狀處理到統一,是後端開發者的責任,不要將錯誤的期待交給 NestJS 的底層去猜測。
  3. 確立 API 傳輸規範:團隊決定好要用「重複 Key」還是「逗號分隔」後,如果是強依賴 DTO 驗證的情境,可用 @Transform();若是單純的參數提取,則可以直接套用內建的 ParseArrayPipe。

參考資料


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

尚未有邦友留言

立即登入留言