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 при неверных параметрах.