Замена as-Casts на Zod Parsing при каждой границе данных в Next.js
Почему преобразование в TypeScript не может защитить вас от изменений API, и как схема Zod проверяет результаты запросов, формы, обработчики маршрутов и действия сервера в Next.js.
Компоненты, созданные с использованием TypeScript, кажутся надежными, пока на этапе производства не поступают поля с измененными именами, значения типа null вместо строки или данные ошибки вместо информации о пользователе. TypeScript не может этого обнаружить: его типы исчезают на момент компиляции, тогда как сетевые данные существуют только во время выполнения программы, поэтому использование оператора as User является лишь утверждением, а не проверкой. В этом руководстве показано, как одна схема Zod может одновременно проверять входящие данные и генерировать соответствующий тип для TypeScript, а также как применять ее на каждом этапе работы приложений React и Next.js: при получении результатов операции fetch, в формах, обработчиках маршрутов и серверных действиях.
Ненадежный JSON — вот настоящая проблема
Любой пакет данных, который не был сгенерирован лично вашим кодом — будь то ответ от fetch, тело запроса, входные данные серверной операции или 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 и сначала определите для нее схему.