Aviary
Client

Receiving array query params

Why ParseArrayPipe 400s the single-value case, and the safe string | string[] pattern (with @QueryList).

Array query params have an inverted footgun on the receiving end. This page covers why the obvious tool breaks, and the pattern (plus a decorator) that handles every shape.

The footgun

Express — and therefore NestJS's default query parser — hands back a bare string when a querystring key carries exactly one value, and a string[] only when it carries two or more:

?ids=a          → req.query.ids === 'a'          (string)
?ids=a&ids=b    → req.query.ids === ['a', 'b']    (string[])

ParseArrayPipe (@nestjs/common) — the obvious tool for "validate this query param is an array" — rejects the single-value form: it expects array-shaped input and 400s on a bare string. So the common case (a user selects exactly one item from a multiselect) is the one that breaks, while the multi-value case passes. Happy-path testing with 2+ values hides the bug; it only surfaces with exactly one value.

This pairs with the client's arrayFormat. Even with arrayFormat: 'repeat' (?ids=a&ids=b), the single-value case is still a bare string on the wire — so the receiving side always needs to accept string | string[].

The pattern: string | string[] + normalize

Accept both shapes and normalize to a clean string[]. A bare string becomes a one-element array; a comma-joined string (the client's arrayFormat: 'comma' default) is split; an absent param becomes []:

function toStringList(raw: unknown): string[] {
  if (raw === undefined || raw === null) return [];
  const arr = Array.isArray(raw) ? raw : String(raw).split(',');
  return arr.map((s) => String(s).trim()).filter(Boolean);
}

@QueryList() decorator

@dudousxd/nestjs-codegen/nest ships this as a param decorator so you don't hand-roll it:

import { QueryList } from '@dudousxd/nestjs-codegen/nest';

@Controller('things')
export class ThingsController {
  @Get()
  list(@QueryList('baseIds') baseIds: string[]) {
    // baseIds is always a clean string[]:
    //   ?baseIds=a           → ['a']
    //   ?baseIds=a&baseIds=b → ['a', 'b']
    //   ?baseIds=a,b         → ['a', 'b']
    //   (absent)             → []
  }
}

For a DTO field instead of a param, reuse the same normalization in a class-transformer @TransformtoStringList is exported for exactly this:

import { Transform } from 'class-transformer';
import { toStringList } from '@dudousxd/nestjs-codegen/nest';

class GetThingsQueryDto {
  @Transform(({ value }) => toStringList(value))
  baseIds: string[] = [];
}

Both accept every wire shape, so they work regardless of the client's arrayFormat. Once the client sends arrayFormat: 'repeat', the comma-split degrades to a no-op fallback that still covers hand-rolled callers and curl.

On this page