← назад к разделу

Query-параметры — это то, что идёт после ? в URL: ?status=CONFIRMED&page=2. В NestJS их принято читать через класс-DTO с декораторами валидации, а не разбирать вручную. Разберём, как это устроено.

Почему DTO, а не req.query

Можно написать @Query('page') page: string и дальше вручную превратить строку в число, проверить диапазон и так далее. Но когда параметров пять-десять, это превращается в скучный повторяющийся код.

В NestJS есть другой способ: описать все параметры в классе, поставить декораторы из class-validator, включить ValidationPipe — и фреймворк сделает всё сам: преобразует типы, проверит ограничения, вернёт 400 с описанием ошибки, если что-то не так.

Глобальная настройка ValidationPipe

Сначала один раз настраиваем в main.ts:

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,    // string → number/boolean/enum автоматически
    whitelist: true,    // отбрасывает лишние параметры
  }),
);

Флаг transform: true критически важен: без него все query-параметры приходят как строки и @IsInt() не сработает.

Именование: camelCase

В URL принято писать ?customerId=..., а не ?customer_id=.... TypeScript-код тоже использует camelCase — алиасы не нужны, имя параметра совпадает с именем поля.

export class OrdersQueryDto {
  @IsOptional()
  @IsEnum(OrderStatus)
  status?: OrderStatus;         // ?status=CONFIRMED

  @IsOptional()
  @IsString()
  customerId?: string;          // ?customerId=550e...

  @IsOptional()
  @IsDateString()
  createdAtFrom?: string;       // ?createdAtFrom=2026-01-01T00:00:00Z

  @IsOptional()
  @IsDateString()
  createdAtTo?: string;         // ?createdAtTo=2026-12-31T23:59:59Z
}

Частая ошибка — написать customer_id или CustomerID. Оба варианта нарушают соглашение: первый — стиль Python/Ruby, второй — PascalCase, который зарезервирован для типов.

Числовые параметры: нужен @Type

HTTP передаёт всё как строки, поэтому числа нужно явно привести:

export class ProductsQueryDto {
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Min(0)
  priceFrom?: number;           // ?priceFrom=100

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Max(1000000)
  priceTo?: number;             // ?priceTo=5000
}

@Type(() => Number) говорит class-transformer: «превратить строку в число перед валидацией». Без него @IsInt() и @IsNumber() всегда будут возвращать ошибку — они получают строку вместо числа.

Пагинация: page всегда с 1

Параметры страницы оформляют в отдельный DTO и переиспользуют везде, где нужна пагинация:

export class PaginationDto {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  size: number = 20;
}

page начинается с 1, не с 0. ?page=0 — ошибка, валидатор её отклонит. Это соответствует тому, как думают пользователи: «первая страница» — это страница номер один.

В контроллере оба DTO принимаем параллельно:

@Controller('orders')
export class OrdersController {
  @Get()
  async findAll(
    @Query() query: OrdersQueryDto,
    @Query() pagination: PaginationDto,
  ) {
    return this.ordersService.findAll(query, pagination);
  }
}

Ответ содержит данные и метаданные пагинации:

{
  "content": [...],
  "page": 1,
  "size": 20,
  "totalElements": 243,
  "totalPages": 13
}

Cursor-пагинация

Offset-пагинация (page=1, page=2, ...) работает хорошо для небольших объёмов. Но при больших таблицах OFFSET 10000 заставляет базу пропустить десять тысяч строк — это медленно. Cursor-пагинация решает эту проблему.

Cursor — это непрозрачный токен, который сервер возвращает вместе с каждой страницей. Клиент передаёт его обратно, чтобы получить следующую порцию данных. Что внутри токена — дело сервера (обычно base64 от идентификатора последнего элемента).

export class CursorPaginationDto {
  @IsOptional()
  @IsString()
  cursor?: string;             // непрозрачный токен от сервера

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  size: number = 20;
}

Ответ:

{
  "content": [...],
  "nextCursor": "eyJpZCI6IjU1MG...",
  "hasMore": true
}

Клиент берёт nextCursor и передаёт его в следующем запросе: ?cursor=eyJpZCI6IjU1MG.... Разбирать содержимое токена на клиенте не нужно — это ломает контракт и привязывает клиента к внутреннему устройству сервера.

Сортировка

Сортировку передают одним параметром: поле и направление через запятую.

export class SortQueryDto {
  @IsOptional()
  @IsString()
  @Matches(/^[a-zA-Z]+,(asc|desc)$/)
  sort?: string;               // ?sort=createdAt,desc
}

На стороне сервера разбираем строку:

function parseSort(sort?: string): { field: string; direction: 'asc' | 'desc' } | undefined {
  if (!sort) return undefined;
  const [field, direction] = sort.split(',');
  return { field, direction: direction as 'asc' | 'desc' };
}

Полнотекстовый поиск

Для простого текстового поиска используют параметр q:

export class SearchQueryDto {
  @IsOptional()
  @IsString()
  @MinLength(2)
  q?: string;                  // ?q=ноутбук
}

@Get()
async findAll(@Query() query: SearchQueryDto) {
  if (query.q) return this.productsService.search(query.q);
  return this.productsService.findAll();
}

Множественные значения: повтор параметра

Если нужно передать несколько значений одного параметра, их повторяют:

?status=CONFIRMED&status=SHIPPED

Писать ?status=CONFIRMED,SHIPPED через запятую — неправильно: разные клиенты разбирают это по-разному, и сервер получает одну строку вместо массива.

export class StatusFilterDto {
  @IsOptional()
  @IsArray()
  @IsEnum(OrderStatus, { each: true })
  @Transform(({ value }) => (Array.isArray(value) ? value : [value]))
  status?: OrderStatus[];      // ?status=CONFIRMED&status=SHIPPED
}

@Transform здесь нужен для случая, когда передано одно значение: Express разбирает его как строку, а не массив из одного элемента.

Сложный поиск через POST /search

Когда фильтров много — даты, диапазоны, вложенные условия — длинный URL становится неудобным и может превысить лимиты браузеров. В таких случаях используют POST /resources/search с JSON-телом:

export class OrderSearchDto {
  @IsOptional()
  @IsString()
  customerId?: string;

  @IsOptional()
  @IsArray()
  @IsEnum(OrderStatus, { each: true })
  statuses?: OrderStatus[];

  @IsOptional()
  @IsDateString()
  createdAtFrom?: string;

  @IsOptional()
  @Type(() => Number)
  @Min(0)
  totalAmountMin?: number;
}

@Controller('orders')
export class OrdersController {
  @Post('search')
  @HttpCode(200)
  async search(@Body() dto: OrderSearchDto) {
    return this.ordersService.search(dto);
  }
}

@HttpCode(200) важен: NestJS по умолчанию отвечает на POST кодом 201, но поиск — это операция чтения, а не создания ресурса.

Коротко

  • ValidationPipe({ transform: true }) глобально — типы из строк преобразуются автоматически.
  • Имена параметров — camelCase: customerId, createdAtFrom.
  • Числовые параметры требуют @Type(() => Number), иначе валидация будет падать.
  • page начинается с 1; page=0 — ошибка.
  • Массивы передаются повтором параметра: ?status=A&status=B, не через запятую.
  • Cursor — непрозрачный токен; клиент передаёт его как есть, не разбирает.
  • Сложный многофильтровый поиск — POST /resources/search с JSON-телом.

Что почитать дальше

  • JSON и формат ответов — структура content + пагинация в ответе.
  • URL и ресурсы — формат пути и @Controller.
  • Обработка ошибок — как NestJS возвращает 400 при неверных параметрах.