Заміна as-Casts на Zod Parsing на кожному межі даних Next.js
Чому перетворення типів у TypeScript не може захистити вас від змін API, та як схема Zod перевіряє результати запитів, форми, обробники маршрутів та дії сервера в Next.js.
Компоненти, створені шляхом введення даних, здаються безпечними, поки система виробництва не надсилатиме поле з іншою назвою, значення null замість рядка чи обгортку з помилкою замість користувача. TypeScript не може цього виявити: його типи зникають під час компіляції, тоді як мережеві дані існують лише під час виконання, тому as User є лише твердженням, а не перевіркою. У цьому посібнику показано, як одна схема Zod може водночас перевіряти надходження даних та створювати відповідний тип у TypeScript, а також як застосувати її на кожному рівні додатку React та Next.js: у результатах fetch, формах, обробниках маршрутів та діях сервера.
Справжня проблема — ненадійний JSON
Кожен навантажуваний даний, який ваш код не створив самостійно — чи то відповідь від fetch, тіло запиту, дані вхіду для Server Action чи webhook — заслуговує на підозру. Якщо пропустити перевірку під час виконання, це призведе до некоректних перетворень типів, валідаторів, які не відповідають інтерфейсам, а також до розбіжностей між типами клієнта та сервера. Zod об’єднує все це в одне визначення: достатньо змінити схему, і виведений тип автоматично зміниться.
Визначте схему, отримайте тип
Почніть з імпорту:
import { z } from "zod";
Наведена нижче схема описує профіль користувача; z.infer перетворює її на тип TypeScript, а loadProfile обробляє відповідь за допомогою parse перед її поверненням.
export const UserProfileSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(1).optional(),
});export type UserProfile = z.infer<typeof UserProfileSchema>;async function loadProfile(id: string): Promise<UserProfile> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return UserProfileSchema.parse(data);
}
Порівняйте return data as UserProfile: обробка викликає помилку як тільки API порушує умови контракту, тоді як перетворення дозволяє некоректним даним проходити далі, поки щось не зламається далеко від первинної причини.
У коді інтерфейсу зазвичай краще використовувати safeParse: він повертає об’єкт з результатом замість того, щоб кидати помилку, тож ви самі контролюєте альтернативний варіант:
const result = UserProfileSchema.safeParse(data);
if (!result.success) {
console.error(result.error.flatten());
return null;
}
Форми, які надсилають обробнику подання коректні дані
За допомогою zodResolver React Hook Form перевіряє значення ще до того, як вони потраплять у handleSubmit. Цей файл є клієнтським компонентом:
"use client";
Повідомлення про помилки полів також походять зі схеми, що забезпечує узгодженість інформації в інтерфейсі та типів:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";const SignupSchema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
});type SignupValues = z.infer<typeof SignupSchema>;export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupValues>({
resolver: zodResolver(SignupSchema),
}); return (
<form onSubmit={handleSubmit((values) => console.log(values))}>
<input type="email" {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Create account</button>
</form>
);
}
У реальному додатку краще перемістити SignupSchema у спільний модуль замість того, щоб визначати його всередині файлу компонента, щоб сервер міг імпортувати однакові правила.
Перевірка даних у точках входу Next.js
Обробники маршрутів
Обробник маршруту потребує NextResponse, бібліотеки Zod та спільної схеми профілю:
import { NextResponse } from "next/server";
import { z } from "zod";
import { UserProfileSchema } from "@/lib/schemas/user";
Обробник перевіряє тіло запиту за допомогою safeParse та у разі невдачі повертає код 400 із об’єднаними помилками. Він також аналізує власну відповідь за допомогою UserProfileSchema, щоб результат відповідав умовам клієнта. Жорстко закодований id використовується замість даних, які були вставлені до бази даних.
const CreateUserSchema = z.object({
email: z.string().email(),
displayName: z.string().min(1).max(80).optional(),
});export async function POST(request: Request) {
const parsed = CreateUserSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid body", details: parsed.error.flatten() },
{ status: 400 }
);
} const created = {
id: "11111111-1111-1111-1111-111111111111",
email: parsed.data.email,
displayName: parsed.data.displayName,
}; return NextResponse.json(UserProfileSchema.parse(created), { status: 201 });
}
Дії сервера
Модуль дій сервера починається з такої директиви:
"use server";
Ця дія створює об’єкт з FormData та перевіряє його за допомогою тієї самої схеми SignupSchema, що використовується у формі. Повернення значення ok у вигляді літерального типу (as const) дозволяє користувачам чітко обробляти результати:
import { SignupSchema } from "@/lib/schemas/auth";export async function signupAction(formData: FormData) {
const parsed = SignupSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
}); if (!parsed.success) {
return { ok: false as const, errors: parsed.error.flatten().fieldErrors };
} return { ok: true as const };
}
Один модуль схеми, який використовується і клієнтом, і сервером, усуває проблему „дані дійсні у формі, але відхилені сервером“. Щодо аналогічного підходу поза Next.js, дивіться спільне використання однієї схеми Zod між фронтендом на React та бекендом на Node.
Звички, які допомагають підтримувати схеми
- Зберігайте схеми разом, наприклад у директорії
lib/schemas/*. - Створюйте варіанти за допомогою
.extend,.pickта.omit, замість того щоб дублювати поля. - Використовуйте
.transformдля незначних операцій очищення, таких як обрізання рядків чи парсинг дат, але ніколи не для прихованих бізнес-правил. - Використовуйте
z.discriminatedUnion, коли форма даних залежить від поля статусу. - Парсуйте змінні середовища один раз під час запуску.
На практиці композиція виглядає ось так. Основна схема містить спільні поля:
const BaseUser = z.object({
email: z.string().email(),
displayName: z.string().optional(),
});
На її основі схема оновлення робить кожне поле необов’язковим за допомогою .partial(), а DTO відповіді додає поля, якими володіє сервер, за допомогою .extend():
export const UpdateUserSchema = BaseUser.partial();
export const UserDtoSchema = BaseUser.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
Є один нюанс: новіші версії Zod ввели формати верхнього рівня, такі як z.email() та z.uuid(), і змінили спосіб відображення помилок. Показані тут ланцюгові формати можуть бути застарілими у вашій версії, тому перевірте актуальну документацію Zod.
Ключові висновки
- Типи описують намір; лише обробка під час виконання забезпечує його дотримання на межі мережі.
- Виводьте типи TypeScript з схем Zod, щоб вони не відхилялися один від одного.
- Використовуйте
safeParse, коли потрібно обробляти помилки, таparse, коли помилка має призводити до виникнення винятку.
as та спочатку надайте йому схему.