Zod aux frontières : valider les données au-delà des formulaires
Publié le
Développeur React & Next.js freelance
Votre API renvoie un User. Vous faites confiance. Vous castez avec as User. Et un jour, le backend change un champ de createdAt: string à created_at: string. Votre code compile toujours. Mais il explose en production.
Sur les 22+ startups que j'ai accompagnées, j'ai vu ce pattern causer des bugs en production plus souvent que je ne voudrais l'admettre. Le problème n'est pas TypeScript. Le problème, c'est qu'on lui ment en utilisant as pour caster des données qu'on n'a jamais validées.
Dans cet article, vous allez découvrir :
- Pourquoi
as MyTypeest une bombe à retardement - Comment valider les réponses API avec Zod
- Le pattern
safeParse+ Result pour une gestion d'erreurs propre - L'utilisation de
coercepour les conversions automatiques - Les autres frontières à protéger (search params, cookies, base de données)
Le problème : faire confiance aux données externes
Le cast as ment au compilateur
Regardez ce code. Il a l'air correct :
type User = {
id: string
email: string
createdAt: Date
}
const getUser = async (id: string): Promise<User> => {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
return data as User
}TypeScript est content. Vous êtes content. Mais ce code ment.
Le as User dit à TypeScript : "Fais-moi confiance, cette donnée est un User". Sauf que TypeScript ne vérifie rien au runtime. Si l'API renvoie { id: "123", email: "test@example.com", created_at: "2026-01-19" } (avec un underscore), votre code compile. Et plante quand vous accédez à user.createdAt.
Les symptômes arrivent plus tard :
Cannot read property 'toISOString' of undefineduser.createdAt is not a function- Des données incohérentes en base
Et vous passez des heures à debugger parce que TypeScript vous avait promis que tout allait bien.
Les frontières de votre système
Toute application a des frontières : les endroits où des données externes entrent dans votre code.
| Source | Exemple | Risque | Valider ? |
|---|---|---|---|
| API externe | Réponse d'un service tiers | Contrat qui change sans prévenir | ✅ Toujours |
| Votre propre API | Réponse backend | Désynchronisation front/back | ✅ Recommandé |
| Base de données | Requête SQL brute, colonnes JSONB | Données non typées | ⚠️ Selon le cas |
| URL | Search params, path params | Manipulation utilisateur | ✅ Toujours |
| Cookies/Storage | Données persistées | Format obsolète après mise à jour | ✅ Toujours |
La règle est simple : toute donnée qui traverse une frontière doit être validée.
Pas "devrait". Doit.
Valider les réponses API avec Zod
Le pattern schema + parse + infer
Zod permet de définir un schema qui sert à la fois de validation runtime ET de définition de type TypeScript :
import { z } from 'zod'
export const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.coerce.date(),
})
export type User = z.infer<typeof userSchema>Le type User est inféré du schema. Un seul endroit à maintenir. Si le schema change, le type change automatiquement.
Maintenant, le service devient :
import { userSchema, type User } from '@/schemas/user'
const getUser = async (id: string): Promise<User> => {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
return userSchema.parse(data)
}Si l'API renvoie des données qui ne correspondent pas au schema, Zod lance une erreur explicite :
ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["createdAt"],
"message": "Required"
}
]
Plus de mystère. Vous savez exactement ce qui ne va pas.
Utiliser coerce pour les conversions automatiques
Les APIs renvoient souvent des données dans des formats qui ne correspondent pas directement à vos types. Les dates arrivent en string. Les nombres arrivent en string depuis les query params. Les booléens arrivent en "true" ou "false".
Zod propose z.coerce pour convertir automatiquement les données. En Zod 4, l'input accepte désormais unknown (au lieu de string en Zod 3), offrant plus de flexibilité :
import { z } from 'zod'
const orderSchema = z.object({
id: z.string(),
total: z.coerce.number(), // "99.99" → 99.99
quantity: z.coerce.number(), // "5" → 5
createdAt: z.coerce.date(), // "2026-01-19T10:00:00Z" → Date
isActive: z.stringbool(), // "true" → true, "false" → false (Zod 4)
})
type Order = z.infer<typeof orderSchema>z.coerce utilise les constructeurs JavaScript natifs (Number(), new Date()). Pour les booléens sous forme de string ("true"/"false"), z.stringbool() (Zod 4) offre une conversion intelligente car Boolean("false") retournerait true (string non vide = truthy).
Personnaliser les messages d'erreur (Zod 4)
Zod 4 simplifie la personnalisation des erreurs avec le paramètre error (qui remplace message de Zod 3) :
import { z } from 'zod'
const userSchema = z.object({
email: z.string().email({ error: 'Format email invalide' }),
age: z.number().min(18, { error: 'Vous devez avoir au moins 18 ans' }),
})Pour des erreurs dynamiques, passez une fonction :
const userSchema = z.object({
username: z.string({
error: (issue) => issue.input === undefined
? 'Le nom d\'utilisateur est requis'
: 'Le nom d\'utilisateur doit être une chaîne de caractères'
}),
})safeParse + Result Pattern : le combo gagnant
Pourquoi safeParse plutôt que parse
parse() lance une exception si la validation échoue. C'est pratique pour les cas simples, mais ça force à utiliser try/catch :
try {
const user = userSchema.parse(data)
} catch (error) {
// Que faire ici ? L'erreur est de type unknown...
}safeParse() retourne un objet discriminé qui indique explicitement le succès ou l'échec :
const result = userSchema.safeParse(data)
if (result.success) {
// result.data est typé User
console.log(result.data.email)
} else {
// result.error contient les détails
console.log(result.error.issues)
}parse() | safeParse() |
|---|---|
| Throw une erreur si invalide | Retourne un objet Result |
| Nécessite try/catch | Gestion explicite du succès/échec |
| Interrompt le flux | Permet de continuer avec l'erreur |
Type de retour : T | Type de retour : SafeParseReturnType<T> |
Intégration avec le Result Pattern
Si vous avez lu mon article sur le Result Pattern, vous savez que je préfère les erreurs explicites aux exceptions. safeParse s'intègre parfaitement avec cette approche.
Voici un wrapper générique pour vos appels API :
import { z, type ZodSchema } from 'zod'
type ApiError =
| { code: 'NETWORK_ERROR'; message: string }
| { code: 'VALIDATION_ERROR'; issues: z.ZodIssue[] }
| { code: 'HTTP_ERROR'; status: number }
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError }
export const fetchAndValidate = async <T>(
url: string,
schema: ZodSchema<T>
): Promise<ApiResult<T>> => {
try {
const response = await fetch(url)
if (!response.ok) {
return {
success: false,
error: { code: 'HTTP_ERROR', status: response.status }
}
}
const data = await response.json()
const result = schema.safeParse(data)
if (!result.success) {
return {
success: false,
error: { code: 'VALIDATION_ERROR', issues: result.error.issues }
}
}
return { success: true, data: result.data }
} catch (err) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: err instanceof Error ? err.message : 'Unknown error'
}
}
}
}Utilisation dans un composant Next.js
Avec ce wrapper, la gestion des erreurs devient explicite et typée :
import { fetchAndValidate } from '@/lib/api'
import { userSchema } from '@/schemas/user'
import { UserProfile } from '@/components/user-profile'
import { ErrorState } from '@/components/error-state'
type PageProps = {
params: Promise<{ id: string }>
}
const UserPage = async ({ params }: PageProps) => {
const { id } = await params
const result = await fetchAndValidate(
`${process.env.API_URL}/users/${id}`,
userSchema
)
if (!result.success) {
if (result.error.code === 'VALIDATION_ERROR') {
// Le contrat API a changé, alerter l'équipe
console.error('API contract violation:', result.error.issues)
}
return <ErrorState error={result.error} />
}
return <UserProfile user={result.data} />
}
export default UserPageL'avantage ? Chaque type d'erreur peut être géré différemment :
NETWORK_ERROR: afficher "Vérifiez votre connexion"HTTP_ERRORavec status 404 : afficher "Utilisateur non trouvé"VALIDATION_ERROR: logger et alerter l'équipe (bug de contrat)
Autres frontières à protéger
Search params et cookies
Les search params arrivent toujours en string. Sans validation, vous manipulez des données non typées :
import { z } from 'zod'
const searchParamsSchema = z.object({
page: z.coerce.number().positive().default(1),
limit: z.coerce.number().min(1).max(100).default(20),
sort: z.enum(['price', 'date', 'name']).default('date'),
category: z.string().optional(),
})
type SearchParams = z.infer<typeof searchParamsSchema>
type PageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const ProductsPage = async ({ searchParams }: PageProps) => {
const rawParams = await searchParams
const params = searchParamsSchema.parse(rawParams)
// params.page est un number garanti (pas string | undefined)
// params.sort est 'price' | 'date' | 'name' (pas string)
const products = await getProducts(params)
return <ProductList products={products} />
}
export default ProductsPageMême logique pour les cookies :
import { cookies } from 'next/headers'
import { z } from 'zod'
const sessionSchema = z.object({
userId: z.string().uuid(),
role: z.enum(['user', 'admin']),
expiresAt: z.coerce.date(),
})
export const getSession = async () => {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('session')
if (!sessionCookie) {
return null
}
try {
const parsed = JSON.parse(sessionCookie.value)
return sessionSchema.parse(parsed)
} catch {
return null
}
}Données de base de données : ça dépend
Avec un ORM typé comme Prisma ou un client comme Supabase, les types sont générés à partir du schema. La validation Zod serait redondante :
// ✅ Prisma génère les types automatiquement
const user = await prisma.user.findUnique({ where: { id } })
// user est typé User | null, pas besoin de ZodEn revanche, la validation reste pertinente pour :
Les requêtes SQL brutes :
import { z } from 'zod'
import { sql } from '@/lib/db'
const analyticsRowSchema = z.object({
date: z.coerce.date(),
views: z.coerce.number(),
unique_visitors: z.coerce.number(),
})
// Requête SQL brute = pas de types générés
const getAnalytics = async (startDate: Date) => {
const rows = await sql`SELECT * FROM analytics WHERE date >= ${startDate}`
return rows.map(row => analyticsRowSchema.parse(row))
}Les colonnes JSONB (le contenu JSON n'est pas typé par Prisma) :
import { z } from 'zod'
const userSettingsSchema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
language: z.string().default('fr'),
})
// Prisma type settings comme JsonValue, pas comme UserSettings
const getUserSettings = async (userId: string) => {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { settings: true }, // settings: JsonValue
})
return userSettingsSchema.parse(user?.settings ?? {})
}Règle pragmatique : si votre ORM génère les types, faites-lui confiance. Validez uniquement les données non typées (SQL brut, JSON, données externes).
Les erreurs à éviter
Valider trop tard
La validation doit se faire à l'entrée, pas au milieu de la logique métier :
// ❌ Validation au milieu du processus
const processOrder = async (data: unknown) => {
const order = await createOrder(data) // data utilisé sans validation
await sendConfirmation(order)
const validated = orderSchema.parse(order) // Trop tard, l'ordre est déjà créé
return validated
}
// ✅ Validation à l'entrée
const processOrder = async (data: unknown) => {
const validatedData = orderSchema.parse(data) // D'abord valider
const order = await createOrder(validatedData) // Puis utiliser
await sendConfirmation(order)
return order
}Ignorer les erreurs de validation
Une erreur de validation Zod signifie souvent un bug de contrat. Ne la faites pas disparaître :
// ❌ Erreur silencieuse
const getUser = async (id: string) => {
const data = await fetch(`/api/users/${id}`).then(r => r.json())
try {
return userSchema.parse(data)
} catch {
return null // Bug caché, impossible à diagnostiquer
}
}
// ✅ Erreur explicite et loggée
const getUser = async (id: string) => {
const data = await fetch(`/api/users/${id}`).then(r => r.json())
const result = userSchema.safeParse(data)
if (!result.success) {
console.error('User API contract violation', {
userId: id,
issues: result.error.issues,
})
return null
}
return result.data
}Dupliquer schema et type
Maintenir un type ET un schema séparément mène à la désynchronisation :
// ❌ Duplication = désynchronisation inévitable
type User = {
id: string
email: string
name: string
}
const userSchema = z.object({
id: z.string(),
email: z.string(),
// Oups, on a oublié name...
})
// ✅ Single source of truth
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
})
type User = z.infer<typeof userSchema>Sur-valider en interne
La validation aux frontières ne signifie pas valider partout. À l'intérieur de votre domaine, faites confiance aux types :
// ❌ Paranoïa excessive
const formatUserEmail = (user: User) => {
const validUser = userSchema.parse(user) // Inutile, user est déjà typé
return validUser.email.toLowerCase()
}
// ✅ Faire confiance aux types internes
const formatUserEmail = (user: User) => {
return user.email.toLowerCase()
}La règle : validez une fois à l'entrée, puis faites confiance à vos types.
FAQ : Questions fréquentes sur Zod
Faut-il valider toutes les réponses API avec Zod ?
Oui pour les APIs externes (services tiers, microservices d'autres équipes). Pour vos propres APIs dans le même monorepo avec des types partagés, c'est optionnel si vous avez une bonne couverture de tests d'intégration. La règle : validez tout ce qui traverse une frontière de confiance.
Zod ralentit-il les performances ?
Négligeable dans 99% des cas. La validation Zod prend quelques microsecondes par objet. À moins de parser des millions d'objets par seconde (traitement de logs, streaming massif), le coût est invisible. La sécurité et la maintenabilité valent largement ce prix.
Quelle différence entre parse et safeParse ?
parse() lance une exception si la validation échoue. Utilisez-le quand une donnée invalide est une erreur fatale. safeParse() retourne un objet { success: true, data } ou { success: false, error }. Utilisez-le quand vous voulez gérer l'erreur explicitement, l'afficher à l'utilisateur, ou la logger sans interrompre le flux.
Comment migrer progressivement vers Zod ?
Commencez par les frontières les plus risquées : réponses d'APIs tierces, webhooks, données utilisateur non fiables. Créez un wrapper fetchAndValidate générique. Ajoutez des schemas au fur et à mesure, en priorisant les endpoints les plus utilisés ou les plus critiques. Pas besoin de tout migrer d'un coup.
Que faire quand la validation échoue en production ?
Loggez et alertez, mais ne plantez pas silencieusement. Une erreur de validation Zod en production signifie souvent qu'un contrat API a changé. Loggez les issues Zod avec le contexte (endpoint, payload partiel), alertez l'équipe (Slack, Sentry), et retournez une erreur explicite à l'utilisateur plutôt qu'un écran blanc.
Conclusion
Résumé des points clés
- Ne faites jamais confiance aux données externes.
as MyTypement au compilateur. - Validez aux frontières : réponses API, search params, cookies, données DB.
- Un schema = un type : utilisez
z.infer<typeof schema>pour éviter la duplication. - safeParse + Result Pattern : gestion d'erreurs explicite, typée, et traçable.
Checklist : sécuriser vos frontières
- Identifier toutes les sources de données externes dans votre application
- Créer un dossier
src/schemas/pour centraliser les schemas Zod - Remplacer les
as Typepar desschema.parse()ouschema.safeParse() - Créer un wrapper
fetchAndValidatepour vos appels API - Ajouter des logs ou alertes quand un contrat API est violé
- Utiliser
z.coercepour les conversions de types automatiques
Pour aller plus loin
- Result Pattern vs Throw : gérer les erreurs proprement en TypeScript
- Discriminated Union Types : typer les états de manière exhaustive
- Architecture Hexagonale en Front-end : séparer domaine et infrastructure
- À venir : Value Objects en TypeScript (Currency, Weight, Email...) pour aller encore plus loin dans le typage métier
La validation aux frontières n'est pas de la paranoïa. C'est de l'hygiène. Votre futur vous remerciera quand une API tierce changera son contrat sans prévenir.

À propos de l'auteur
Je suis Dimitri Dumont, développeur freelance spécialisé React & Next.js depuis plus de 7 ans. J'ai accompagné 22 startups et réalisé 43 missions avec une note de 5/5. J'applique ces patterns au quotidien pour continuer de livrer rapidement du code évolutif. En savoir plus →
Cet article vous a été utile ?
Je peux vous accompagner sur votre projet React & Next.js.
Discutons de votre projet →