Strona główna / Artykuły / Zastępowanie as-Casts przez Zod Parsing przy każdej granicy danych w Next.js

Zastępowanie as-Casts przez Zod Parsing przy każdej granicy danych w Next.js

Dlaczego przekształcenie typu w TypeScript nie chroni przed zmianami API, oraz jak schemat Zod waliduje wyniki fetch, formy, obsługę tras i działania serwera w Next.js.

1102 słów

Komponenty napisane w tym języku wydają się bezpieczne, dopóki produkcja nie prześle pola o zmienionej nazwie, wartości null zamiast ciągu znaków lub błędu zamiast użytkownika. TypeScript nie może tego wykryć – jego typy znikają w czasie kompilacji, podczas gdy dane sieciowe istnieją tylko w czasie wykonywania, więc as User jest jedynie stwierdzeniem, a nie sprawdzeniem. Ten przewodnik pokazuje, jak pojedyncze schemat Zod może zarówno weryfikować przychodzące dane, jak i tworzyć odpowiedni typ w TypeScriptie, oraz jak zastosować je na każdym etapie aplikacji React i Next.js: przy wynikach fetch, formularzach, obsługujących trasę oraz działaniach serwera.

Prawdziwym problemem jest niezaufane JSON

Każdy ciężar danych, którego twój kod nie stworzył samodzielnie – czy to odpowiedź z fetch, ciało żądania, dane wejściowe do Server Action czy webhook – zasługuje na podejrzenie. Jeśli pominiesz weryfikację w czasie wykonywania, pojawią się niekontrolowane przekształcenia typów, walidatory, które odbiegają od określonych interfejsów, oraz typy klienta i serwera, które się ze sobą nie zgadzają. Zod łączy to wszystko w jedną definicję: wystarczy edytować schemat, a typ zostanie automatycznie zaktualizowany.

Zdefiniuj schemat, wywnioskuj typ

Zacznij od importu:

import { z } from "zod";

Poniższy schemat opisuje profil użytkownika, z.infer przekształca go w typ TypeScript, a loadProfile przetwarza odpowiedź za pomocą parse przed jej zwróceniem.

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);
}

Porównaj return data as UserProfile: parsowanie powoduje błąd, gdy tylko API narusza umowę, natomiast przekształcenie typu pozwala na przesyłanie nieprawidłowych danych, aż do momentu awarii daleko od źródła problemu.

W kodzie interfejsu użytkownika zazwyczaj lepszym rozwiązaniem jest safeParse: zwraca on obiekt z wynikiem zamiast rzucać błędem, dzięki czemu sam decydujesz o rozwiązaniu awaryjnym:

const result = UserProfileSchema.safeParse(data);
if (!result.success) {
  console.error(result.error.flatten());
  return null;
}

Formularze, które przekazują do funkcji obsługi wysyłki poprawne dane

Za pomocą zodResolver React Hook Form weryfikuje wartości przed tym, jak dotrą one do handleSubmit. Ten plik stanowi komponent kliencki:

"use client";

Wiadomości o błędach w polach pochodzą również ze schematu, co zapewnia zgodność informacji zwrotnych w interfejsie z typami danych:

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>
  );
}

W rzeczywistym aplikacji warto przenieść SignupSchema do wspólnego modułu zamiast definiować go wewnątrz pliku komponentu, aby serwer mógł importować identyczne zasady.

Weryfikacja w punktach wejścia Next.js

Obsługa tras

Obsługa trasy wymaga obiektu NextResponse, biblioteki Zod oraz wspólnego schematu profilu:

import { NextResponse } from "next/server";
import { z } from "zod";
import { UserProfileSchema } from "@/lib/schemas/user";

Obsługa waliduje treść za pomocą safeParse i w przypadku niepowodzenia zwraca kod 400 wraz ze spłaszczonymi błędami. Ponadto analizuje własną odpowiedź pod kątem UserProfileSchema, dzięki czemu wynik jest zgodny z umową klienta. Łącznie skonfigurowany id zastępuje operację wprowadzania danych do bazy.

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 });
}

Działania serwera

Moduł Działania Serwera zaczyna się od dyrektywy:

"use server";

Działanie tworzy obiekt na podstawie FormData i waliduje go według tego samego SignupSchema, który był używany w formularzu. Zwracanie wartości ok jako typu literalnego (as const) umożliwia wywołującym czyste zawężenie wyniku:

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 };
}

Jeden moduł schematu udostępniany zarówno przez klienta, jak i serwer eliminuje problem „danych ważnych w formularzu, odrzuconych przez serwer”. Aby zapoznać się z podobnym podejściem poza Next.js, sprawdź udostępnianie jednego schematu Zod pomiędzy frontendem React a backendem Node.

Nawyki zapewniające łatwą konserwację schematów

  • Zachowuj schematy razem, na przykład w katalogu lib/schemas/*.
  • Twórz warianty za pomocą metod .extend, .pick i .omit zamiast kopiować pola.
  • Wykorzystuj metodę .transform do prostych operacji takich jak usuwanie spacji z ciągów znaków lub parsowanie dat, a nie do realizacji ukrytych zasad biznesowych.
  • Używaj funkcji z.discriminatedUnion, gdy struktura danych zależy od pola statusu.
  • Parsuj zmienne środowiskowe tylko raz, podczas uruchamiania aplikacji.

W praktyce kompozycja wygląda w ten sposób. Podstawowe schemat zawiera wspólne pola:

const BaseUser = z.object({
  email: z.string().email(),
  displayName: z.string().optional(),
});

Na jego podstawie schemat aktualizacji czyni każde pole opcjonalnym za pomocą .partial(), a DTO odpowiedzi dodaje pola należące do serwera za pomocą .extend():

export const UpdateUserSchema = BaseUser.partial();
export const UserDtoSchema = BaseUser.extend({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
});

Jedna uwaga: nowsze wersje Zod wprowadziły formaty najwyższego poziomu, takie jak z.email() i z.uuid(), oraz zmieniły sposób prezentacji spłaszczania błędów. Pokazane tutaj łańcuchowe formy mogą być przestarzałe w twojej wersji, więc sprawdź aktualne dokumentacje Zod.

Główne wnioski

  • Typy opisują intencję; tylko analiza w czasie wykonywania egzekwuje ją na granicy sieci.
  • Wyprowadzaj typy TypeScript z schematów Zod, aby oba nie rozchodziły się od siebie.
  • Wybieraj safeParse, gdy chcesz obsłużyć błędy, a parse, gdy błąd powinien być rzucony.
  • Wykorzystaj jeden schemat dla formularza, obsługi i akcji.
  • Wybierz najbardziej ryzykowną konwersję typu as i najpierw przypisz jej schemat.
  • Literatura pokrewna