Главная / Статьи / Замена as-Casts на Zod Parsing при каждой границе данных в Next.js

Замена as-Casts на Zod Parsing при каждой границе данных в Next.js

Почему преобразование в TypeScript не может защитить вас от изменений API, и как схема Zod проверяет результаты запросов, формы, обработчики маршрутов и действия сервера в Next.js.

1102 слов

Компоненты, созданные с использованием 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 и сначала определите для нее схему.