TypeScriptZodNext.jsClean Code

Zod aux frontières : valider les données au-delà des formulaires

Publié le

Dimitri Dumont
Dimitri Dumont

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 MyType est une bombe à retardement
  • Comment valider les réponses API avec Zod
  • Le pattern safeParse + Result pour une gestion d'erreurs propre
  • L'utilisation de coerce pour 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 :

src/services/user-service.ts
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 undefined
  • user.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.

SourceExempleRisqueValider ?
API externeRéponse d'un service tiersContrat qui change sans prévenir✅ Toujours
Votre propre APIRéponse backendDésynchronisation front/back✅ Recommandé
Base de donnéesRequête SQL brute, colonnes JSONBDonnées non typées⚠️ Selon le cas
URLSearch params, path paramsManipulation utilisateur✅ Toujours
Cookies/StorageDonnées persistéesFormat 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 :

src/schemas/user.ts
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 :

src/services/user-service.ts
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é :

src/schemas/order.ts
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) :

src/schemas/user.ts
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 :

src/schemas/user.ts
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 invalideRetourne un objet Result
Nécessite try/catchGestion explicite du succès/échec
Interrompt le fluxPermet de continuer avec l'erreur
Type de retour : TType 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 :

src/lib/api.ts
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 :

src/app/users/[id]/page.tsx
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 UserPage

L'avantage ? Chaque type d'erreur peut être géré différemment :

  • NETWORK_ERROR : afficher "Vérifiez votre connexion"
  • HTTP_ERROR avec 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 :

src/app/products/page.tsx
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 ProductsPage

Même logique pour les cookies :

src/lib/auth.ts
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 :

src/repositories/user-repository.ts
// ✅ Prisma génère les types automatiquement
const user = await prisma.user.findUnique({ where: { id } })
// user est typé User | null, pas besoin de Zod

En revanche, la validation reste pertinente pour :

Les requêtes SQL brutes :

src/repositories/analytics-repository.ts
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) :

src/repositories/settings-repository.ts
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

  1. Ne faites jamais confiance aux données externes. as MyType ment au compilateur.
  2. Validez aux frontières : réponses API, search params, cookies, données DB.
  3. Un schema = un type : utilisez z.infer<typeof schema> pour éviter la duplication.
  4. 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 Type par des schema.parse() ou schema.safeParse()
  • Créer un wrapper fetchAndValidate pour vos appels API
  • Ajouter des logs ou alertes quand un contrat API est violé
  • Utiliser z.coerce pour les conversions de types automatiques

Pour aller plus loin

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.

Dimitri Dumont

À 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 →

Articles similaires