Пошук уроків, статей та іншого контенту
Валідуйте тіло, параметри й query API-запитів за допомогою схем Zod та повертайте зрозумілі помилки.
API отримує дані від клієнта, якому не можна повністю довіряти. Клієнт може надіслати:
відсутнє обов’язкове поле;
неправильний тип даних;
некоректний ідентифікатор у параметрах маршруту;
значення query-параметра поза допустимим діапазоном;
некоректний JSON.
Zod дає змогу описати очікувану структуру даних у вигляді схеми та перевірити її під час виконання.
Для API це особливо корисно, оскільки одна схема:
перевіряє дані;
повідомляє, у чому саме помилка;
може перетворити значення, наприклад рядок "2" на число 2;
використовується як джерело типів TypeScript.
Встановіть Zod у проєкті Next.js:
npm install zodУ Route Handler тіло запиту читається за допомогою request.json():
const rawBody = await request.json();Але результат request.json() не має гарантованої структури. Тому його потрібно перевірити схемою Zod.
Для перевірки без створення винятку використовується safeParse:
const result = schema.safeParse(value);
if (!result.success) {
// Дані не пройшли валідацію
console.log(result.error.issues);
} else {
// result.data має перевірений тип
console.log(result.data);
}Розглянемо схему для створення оновлення товару:
import { z } from "zod";
const updateProductSchema = z.object({
name: z.string().trim().min(2, "Назва має містити щонайменше 2 символи"),
price: z.number().finite().positive("Ціна має бути більшою за нуль"),
description: z.string().trim().max(500).optional(),
});Схема вимагає:
name — рядок довжиною щонайменше два символи;
price — скінченне додатне число;
description — необов’язковий рядок довжиною до 500 символів.
Важливо: z.number() не перетворює рядок "100" на число. Якщо API має приймати числові значення саме як числа, це корисна поведінка: помилка клієнта не буде непомітно замаскована.
Параметри динамічного маршруту в Next.js зазвичай приходять як рядки.
Для маршруту:
/api/products/42значення id спочатку має тип string, навіть якщо воно виглядає як число.
Для перевірки та перетворення можна використати z.coerce.number():
const paramsSchema = z.object({
id: z.coerce.number().int().positive("ID має бути додатним цілим числом"),
});Схема прийме значення "42" і поверне число 42. Значення "abc", "0" або "-3" не пройдуть валідацію.
Query-параметри також надходять як рядки. Наприклад:
/api/products/42?page=2&includeDetails=trueДля них можна описати окрему схему:
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
includeDetails: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
});Після валідації:
{
page: 2,
includeDetails: true
}z.coerce.number() перетворює значення query-параметра на число.
Для булевих значень не варто використовувати z.coerce.boolean() без додаткової перевірки. У JavaScript непорожній рядок, зокрема "false", є істинним значенням. Тому безпечніше спочатку дозволити лише "true" або "false", а потім перетворити рядок за допомогою transform.
Створимо файл:
app/api/products/[id]/route.tsЦей обробник прийматиме POST-запит і перевірятиме:
id у параметрах маршруту;
page та includeDetails у query;
name, price і description у тілі запиту.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const paramsSchema = z.object({
id: z.coerce.number().int().positive("ID має бути додатним цілим числом"),
});
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
includeDetails: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
});
const updateProductSchema = z.object({
name: z.string().trim().min(2, "Назва має містити щонайменше 2 символи"),
price: z.number().finite().positive("Ціна має бути більшою за нуль"),
description: z.string().trim().max(500).optional(),
});
function formatIssues(
source: string,
issues: z.ZodIssue[],
) {
return issues.map((issue) => ({
source,
field: issue.path.length > 0 ? issue.path.join(".") : source,
message: issue.message,
}));
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
const rawParams = await context.params;
const paramsResult = paramsSchema.safeParse(rawParams);
if (!paramsResult.success) {
return NextResponse.json(
{
error: "Помилка валідації параметрів маршруту",
details: formatIssues("params", paramsResult.error.issues),
},
{ status: 422 },
);
}
const searchParams = Object.fromEntries(
request.nextUrl.searchParams.entries(),
);
const queryResult = querySchema.safeParse(searchParams);
if (!queryResult.success) {
return NextResponse.json(
{
error: "Помилка валідації query-параметрів",
details: formatIssues("query", queryResult.error.issues),
},
{ status: 422 },
);
}
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: "Тіло запиту має бути коректним JSON",
},
{ status: 400 },
);
}
const bodyResult = updateProductSchema.safeParse(rawBody);
if (!bodyResult.success) {
return NextResponse.json(
{
error: "Помилка валідації тіла запиту",
details: formatIssues("body", bodyResult.error.issues),
},
{ status: 422 },
);
}
const { id } = paramsResult.data;
const { page, includeDetails } = queryResult.data;
const { name, price, description } = bodyResult.data;
return NextResponse.json({
message: "Дані пройшли валідацію",
product: {
id,
name,
price,
description,
},
options: {
page,
includeDetails,
},
});
}У сучасних версіях Next.js параметри Route Handler можуть бути асинхронними, тому в прикладі використовується:
context: { params: Promise<{ id: string }> }і:
const rawParams = await context.params;Після валідації paramsResult.data, queryResult.data та bodyResult.data містять уже перевірені й перетворені значення.
Наприклад, запит можна виконати так:
curl -X POST "http://localhost:3000/api/products/42?page=2&includeDetails=true" \
-H "Content-Type: application/json" \
-d '{
"name": "Механічна клавіатура",
"price": 3499,
"description": "Клавіатура з механічними перемикачами"
}'Результат:
{
"message": "Дані пройшли валідацію",
"product": {
"id": 42,
"name": "Механічна клавіатура",
"price": 3499,
"description": "Клавіатура з механічними перемикачами"
},
"options": {
"page": 2,
"includeDetails": true
}
}Якщо надіслати некоректне тіло:
{
"name": "A",
"price": -10
}API може повернути:
{
"error": "Помилка валідації тіла запиту",
"details": [
{
"source": "body",
"field": "name",
"message": "Назва має містити щонайменше 2 символи"
},
{
"source": "body",
"field": "price",
"message": "Ціна має бути більшою за нуль"
}
]
}Поле details зручно обробляти на клієнті: кожна помилка містить джерело, назву поля та повідомлення для користувача.
Для помилок формату запиту зазвичай використовують:
400 Bad Request — некоректний JSON або загалом неправильний формат запиту;
422 Unprocessable Entity — JSON коректний, але його значення не відповідають схемі.
Головне — обрати послідовну угоду для всього API.
Zod може створити TypeScript-тип на основі схеми:
type UpdateProductInput = z.infer<typeof updateProductSchema>;Тип UpdateProductInput буде еквівалентним перевіреній структурі:
type UpdateProductInput = {
name: string;
price: number;
description?: string;
};Якщо схема зміниться, тип також зміниться автоматично. Це допомагає уникати дублювання між схемою валідації та TypeScript-типами.
Для схем із transform потрібно враховувати, що тип результату може відрізнятися від типу вхідних даних:
type QueryInput = z.input<typeof querySchema>;
type QueryOutput = z.output<typeof querySchema>;У цьому прикладі:
QueryInput містить рядкові значення query-параметрів;
QueryOutput містить page як число та includeDetails як boolean.
parse і safeParseZod має два основні способи валідації.
safeParseconst result = schema.safeParse(value);
if (!result.success) {
// Обробка помилки
} else {
// Використання result.data
}Цей спосіб зручний у Route Handler, оскільки дозволяє самостійно сформувати HTTP-відповідь.
parseconst data = schema.parse(value);Якщо значення не відповідає схемі, parse викидає виняток ZodError. У Route Handler цей виняток потрібно перехоплювати, інакше клієнт не отримає контрольовану структуру помилки.
Для API-валідації зазвичай зручніше використовувати safeParse.
Неправильно:
const body = await request.json();
const productName = body.name.trim();
const result = updateProductSchema.safeParse(body);Якщо body.name відсутнє або не є рядком, помилка виникне ще до валідації.
Правильно спочатку перевірити дані:
const body = await request.json();
const result = updateProductSchema.safeParse(body);
if (!result.success) {
// Повернення помилки
}
const productName = result.data.name;request.json() без обробки помилкиНекоректний JSON призведе до винятку під час виконання request.json(). Обгорніть виклик у try...catch і поверніть клієнту зрозумілу відповідь із кодом 400.
Значення:
?page=2надходить як рядок "2", а не як число 2. Для таких випадків використовуйте z.coerce.number() або перевіряйте рядок окремо.
Не перетворюйте "false" на boolean за допомогою простого приведення:
Boolean("false"); // trueСпочатку обмежте допустимі значення через z.enum(["true", "false"]), а потім виконайте явне перетворення.
ZodError без контролюresult.error.issues корисний під час розробки, але формат відповіді API краще визначити самостійно. Так клієнт залежатиме від вашого стабільного формату, а не від внутрішньої структури бібліотеки.
Описуйте тіло, параметри маршруту та query-параметри окремими Zod-схемами.
Використовуйте safeParse, щоб контрольовано обробляти помилки.
Пам’ятайте, що параметри маршруту та query-параметри надходять як рядки.
Використовуйте z.coerce для безпечного перетворення чисел.
Для boolean-значень явно обмежуйте допустимі рядки.
Повертайте зрозумілу структуру помилки з назвою поля та повідомленням.
Використовуйте z.infer, щоб отримувати TypeScript-типи без дублювання описів.