Remplacer les as-Casts par le parsing Zod à chaque limite de données de Next.js
Pourquoi un cast TypeScript ne peut pas vous protéger du dérive d’API, et comment un schéma Zod valide les résultats de fetch, les formulaires, les Route Handlers et les Server Actions dans Next.js.
Les composants générés par saisie semblent sécurisés tant que la production n’envoie pas un champ renommé, une valeur null au lieu d’une chaîne de caractères, ou un envoi d’erreur au lieu d’un utilisateur. TypeScript ne peut pas détecter cela : ses types disparaissent au moment de la compilation, tandis que les données réseau n’existent qu’en temps de exécution ; par conséquent, as User constitue une assertion et non une vérification. Ce guide montre comment un seul schéma Zod permet à la fois de valider les données reçues et de générer le type TypeScript, ainsi que comment l’appliquer à chaque point d’entrée d’une application React et Next.js : les résultats de fetch, les formulaires, les gestionnaires de route et les actions serveur.
Le vrai problème, c’est le JSON non fiable
Toute charge utile que votre code n’a pas construite lui-même, qu’il s’agisse d’une réponse fetch, d’un corps de requête, des entrées d’une action serveur ou d’un webhook, mérite suspicion. En omettant la vérification en temps de exécution, on se retrouve avec des conversions aveugles, des validateurs qui s’écartent des interfaces, ainsi que des types côté client et serveur qui ne coïncident pas. Zod résout cela en unifiant ces éléments : il suffit de modifier le schéma pour que le type déduit change en conséquence.
Définir le schéma, dériver le type
Commencez par l’import :
import { z } from "zod";
Le schéma ci-dessous décrit un profil utilisateur ; z.infer le transforme en type TypeScript, et loadProfile fait passer la réponse par parse avant de la renvoyer.
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);
}
Comparez return data as UserProfile : la parsing génère une erreur dès que l’API ne respecte plus le contrat, tandis que la conversion permet aux données incorrectes de circuler jusqu’à ce qu’un problème survienne loin de la cause initiale.
Dans le code d’interface utilisateur, safeParse est généralement préférable : il renvoie un objet de résultat au lieu de lancer une exception, ce qui vous permet de gérer les cas alternatifs :
const result = UserProfileSchema.safeParse(data);
if (!result.success) {
console.error(result.error.flatten());
return null;
}
Formulaires qui transmettent des données valides au gestionnaire de soumission
Avec zodResolver, React Hook Form valide les valeurs avant qu’elles n’atteignent handleSubmit. Le fichier correspond à un composant client :
"use client";
Les messages d’erreur des champs proviennent également du schéma, ce qui assure que les retours à l’utilisateur et les types restent en phase :
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>
);
}
Dans une application réelle, déplacez SignupSchema dans un module partagé plutôt que de le définir à l’intérieur du fichier du composant, afin que le serveur puisse importer les mêmes règles.
Vérification des données aux points d’entrée de Next.js
Gestionnaires de route
NextResponse, de Zod ainsi que du schéma de profil partagé :
import { NextResponse } from "next/server";
import { z } from "zod";
import { UserProfileSchema } from "@/lib/schemas/user";
Le gestionnaire valide le corps avec safeParse et renvoie un code 400 accompagné des erreurs détaillées en cas d’échec. Il analyse également sa propre réponse selon UserProfileSchema, de sorte que le résultat respecte les exigences du client. L’id codé en dur remplace l’insertion dans la base de données.
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 });
}
Actions serveur
"use server";
L’action crée un objet à partir de FormData et le valide selon le même SignupSchema que celui utilisé par le formulaire. Le fait de renvoyer ok en tant que type littéral (as const) permet aux appelsants de filtrer proprement les résultats :
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 };
}
Un module de schéma partagé entre le client et le serveur met fin au problème « valide dans le formulaire, rejeté par le serveur ». Pour un schéma similaire en dehors de Next.js, consultez partager un schéma Zod entre un frontend React et un backend Node.
Habitudes pour maintenir les schémas faciles à gérer
- Réunissez les schémas ensemble, par exemple sous
lib/schemas/*. - Dérivez des variantes à l’aide de
.extend,.picket.omitplutôt que de dupliquer des champs. - Utilisez
.transformpour de petites opérations de nettoyage comme la suppression des espaces ou l’analyse des dates, jamais pour des règles métier cachées. - Utilisez
z.discriminatedUnionlorsque la structure d’un envoi dépend d’un champ de statut. - Analyssez les variables d’environnement une seule fois, au démarrage.
En pratique, la composition se présente ainsi. Un schéma de base contient des champs partagés :
const BaseUser = z.object({
email: z.string().email(),
displayName: z.string().optional(),
});
À partir de celui-ci, un schéma d’actualisation rend chaque champ optionnel à l’aide de .partial(), et un DTO de réponse ajoute des champs appartenant au serveur via .extend() :
export const UpdateUserSchema = BaseUser.partial();
export const UserDtoSchema = BaseUser.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
Un bémol : les versions plus récentes de Zod ont introduit des formats de niveau supérieur tels que z.email() et z.uuid(), ainsi que modifié la manière dont le débogage des erreurs est géré. Les formes enchaînées présentées ici pourraient être dépréciées dans votre version, il convient donc de consulter la documentation actuelle de Zod.
Points clés
- Les types décrivent l’intention ; seuls le parsing en temps de exécution et le traitement réseau le font respecter.
- Déduisez les types TypeScript à partir des schémas Zod afin d’éviter tout écart entre eux.
- Préférez
safeParselorsque vous souhaitez gérer les échecs, etparselorsque les échecs doivent provoquer une exception.
as la plus risquée et attribuez-lui d’abord un schéma.