React - Types et Validation avec Zod & TypeScript

Salut ! Maintenant qu'on sait faire des formulaires, parlons de la validation des données


Le Problème avec la Validation

TypeScript, c'est bien mais

TypeScript te protège pendant le développement, mais :

// ✅ TypeScript valide ça au build
interface User {
  name: string
  age: number
  email: string
}

function processUser(user: User) {
  // TypeScript sait que user.name est un string
  console.log(user.name.toUpperCase())
}

// ❌ Mais ça peut arriver à runtime !
const userFromAPI = JSON.parse(response) // any 😱
processUser(userFromAPI) // BOOM si userFromAPI n'a pas la bonne structure !

Les Dangers du Runtime

  • 🌐 APIs externes : peuvent renvoyer n'importe quoi
  • 📝 Formulaires utilisateur : données non fiables
  • 🏪 LocalStorage/SessionStorage : peut être corrompu
  • 🔗 URL parameters : manipulables côté client

La Solution ? Validation Runtime

Il nous faut valider les données à l'exécution ET avoir la sécurité des types TypeScript !


Zod - Le Saint Graal ! ⚡

Installation

npm install zod

Philosophie de Zod

  1. Schema-first : tu définis le schéma une fois
  2. Type inference : TypeScript types générés automatiquement
  3. Runtime validation : validation à l'exécution
  4. Developer-friendly : messages d'erreur clairs

Premier Exemple

import { z } from 'zod'

// 🔥 Définis le schéma une fois
const UserSchema = z.object({
  name: z.string().min(1, 'Nom requis'),
  age: z.number().min(0).max(120),
  email: z.string().email('Email invalide'),
})

// ✅ Type TypeScript généré automatiquement !
type User = z.infer<typeof UserSchema>
// type User = { name: string; age: number; email: string }

// 🛡️ Validation runtime
function createUser(data: unknown): User {
  // Ça peut throw une ZodError si invalid
  return UserSchema.parse(data)
}

// 🚀 Utilisation
try {
  const user = createUser({
    name: "Andy",
    age: 25,
    email: "andy@example.com"
  })
  console.log(user.name) // TypeScript sait que c'est un string !
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues) // Détails des erreurs
  }
}

Validation de Données API

Problème Classique

// ❌ Code dangereux qu'on voit partout
async function getUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  const user = await response.json() // any - DANGER !
  
  // Qu'est-ce qui se passe si l'API renvoie autre chose ?
  return user.name.toUpperCase() // PEUT CRASH !
}

Solution Zod

import { z } from 'zod'

// Schéma de validation
const UserApiSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  avatar: z.string().url().optional(),
  createdAt: z.string().datetime(),
  settings: z.object({
    theme: z.enum(['light', 'dark']),
    notifications: z.boolean()
  })
})

type UserApi = z.infer<typeof UserApiSchema>

async function getUser(id: string): Promise<UserApi> {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) throw new Error('API Error')
    
    const data = await response.json()
    
    // 🛡️ Validation des données de l'API
    const user = UserApiSchema.parse(data)
    
    return user // 100% sûr maintenant !
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Invalid API response:', error.issues)
    }
    throw error
  }
}

// 🚀 Utilisation sûre
const user = await getUser('123')
console.log(user.name.toUpperCase()) // ✅ Sûr !
console.log(user.settings.theme) // ✅ TypeScript autocomplete parfait

Validation de Formulaires Avancée

Schémas Complexes

const ContactFormSchema = z.object({
  // Validation string avancée
  name: z.string()
    .min(1, 'Nom requis')
    .min(2, 'Au moins 2 caractères')
    .max(50, 'Max 50 caractères')
    .regex(/^[a-zA-ZÀ-ÿ\s]+$/, 'Caractères invalides'),
  
  // Email avec domaine whitelist
  email: z.string()
    .email('Format email invalide')
    .refine(
      email => ['gmail.com', 'outlook.com', 'company.com'].includes(email.split('@')[1]),
      'Domaine email non autorisé'
    ),
  
  // Numéro de téléphone français
  phone: z.string()
    .regex(/^(?:(?:\+33|0)[1-9](?:[0-9]{8}))$/, 'Numéro français invalide')
    .optional(),
  
  // Âge avec validation métier
  age: z.number()
    .min(16, 'Vous devez avoir au moins 16 ans')
    .max(100, 'Âge invalide'),
  
  // Password avec critères de sécurité
  password: z.string()
    .min(8, 'Au moins 8 caractères')
    .regex(/[A-Z]/, 'Au moins une majuscule')
    .regex(/[a-z]/, 'Au moins une minuscule')
    .regex(/[0-9]/, 'Au moins un chiffre')
    .regex(/[^A-Za-z0-9]/, 'Au moins un caractère spécial'),
  
  // Confirmation mot de passe
  confirmPassword: z.string(),
  
  // Acceptation conditions
  acceptTerms: z.boolean().refine(val => val === true, 'Conditions requises'),
  
  // Array de hobbies avec validation
  hobbies: z.array(
    z.object({
      name: z.string().min(1),
      level: z.enum(['beginner', 'intermediate', 'expert'])
    })
  ).min(1, 'Au moins un hobby').max(5, 'Max 5 hobbies')
})
  // Validation cross-field
  .refine(data => data.password === data.confirmPassword, {
    message: 'Mots de passe différents',
    path: ['confirmPassword'] // Erreur sur le champ confirmPassword
  })

type ContactFormData = z.infer<typeof ContactFormSchema>

Validation API Routes (Next.js)

Server-Side Validation

// app/api/users/route.ts
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().min(16).max(100)
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    
    // 🛡️ Validation des données reçues
    const userData = CreateUserSchema.parse(body)
    
    // Données validées, on peut les utiliser en sécurité
    const user = await createUserInDatabase(userData)
    
    return NextResponse.json({ success: true, user })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { 
          error: 'Validation failed',
          details: error.issues 
        },
        { status: 400 }
      )
    }
    
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Techniques Avancées

Schémas Conditionnels

const UserSchema = z.object({
  type: z.enum(['admin', 'user']),
  name: z.string(),
  email: z.string().email()
})
  .and(
    z.discriminatedUnion('type', [
      z.object({
        type: z.literal('admin'),
        permissions: z.array(z.string()).min(1)
      }),
      z.object({
        type: z.literal('user'),
        department: z.string()
      })
    ])
  )

Transformation de Données

const DateSchema = z.string()
  .datetime()
  .transform(str => new Date(str))

const PriceSchema = z.string()
  .regex(/^\d+(\.\d{2})?$/)
  .transform(str => parseFloat(str))

const ProductSchema = z.object({
  name: z.string(),
  price: PriceSchema, // string → number
  createdAt: DateSchema // string → Date
})

Ressources Pour Aller Plus Loin