TypeScript en pratique - 02 - Zod : validation runtime et inference de types

Comment utiliser Zod pour valider les donnees aux frontieres de ton application et inferer les types TypeScript automatiquement depuis les schemas.

02 - Zod : validation runtime et inference de types

Ce que tu vas apprendre

  • Pourquoi la validation runtime est indispensable malgre TypeScript
  • Comment définir des schemas Zod et inferer les types avec z.infer
  • Les validations courantes : objets, tableaux, unions, transformations
  • L'intégration avec les APIs, les formulaires et les variables d'environnement

Prerequisites

Avoir lu les articles sur le type erasure et le tsconfig.


Le problème que TypeScript ne resout pas

On l'a vu dans l'article sur le type erasure : les types disparaissent a la compilation. Quand des donnees arrivent de l'extérieur (API, formulaire, base de donnees, fichier JSON), TypeScript ne les valide pas.

typescriptinterface User {
  id: number
  name: string
  email: string
}

// ❌ Aucune validation — si l'API retourne { id: "abc" }, crash silencieux
const user: User = await fetch("/api/user/1").then(r => r.json())

Zod resout ca en definissant un schema qui est a la fois une validation runtime et un type TypeScript.

Un schema Zod

typescriptimport { z } from "zod"

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email()
})

// Le type est derive du schema — pas de duplication
type User = z.infer<typeof UserSchema>
// { id: number; name: string; email: string }

Un seul endroit definit la structure. Le type et la validation sont synchronises. Si tu ajoutes un champ au schema, le type se met à jour automatiquement.

Valider des donnees

typescript// .parse() lance une erreur si invalide
const user = UserSchema.parse(data) // type: User ou throw ZodError

// .safeParse() retourne un resultat discrimine
const result = UserSchema.safeParse(data)
if (result.success) {
  console.log(result.data.name) // type: User ✅
} else {
  console.log(result.error.issues) // details des erreurs
}

.safeParse est le pattern Result applique a la validation. L'appelant est force de gerer les deux cas.

Types de schemas

Primitifs

typescriptz.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()

Validations sur les strings

typescriptz.string().min(1)          // au moins 1 caractere
z.string().max(255)        // au plus 255
z.string().email()         // format email
z.string().url()           // format URL
z.string().uuid()          // format UUID
z.string().regex(/^[a-z]+$/) // regex custom
z.string().startsWith("user_")
z.string().trim()          // trim avant validation

Validations sur les numbers

typescriptz.number().int()           // entier
z.number().positive()      // > 0
z.number().min(0).max(100) // entre 0 et 100
z.number().finite()        // pas Infinity

Objets

typescriptconst AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}$/),
  country: z.string().default("FR")
})

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  address: AddressSchema,        // objet imbrique
  tags: z.array(z.string()),     // tableau de strings
  role: z.enum(["admin", "user", "editor"]) // union litterale
})

Unions et discriminated unions

typescript// Union simple
const StringOrNumber = z.union([z.string(), z.number()])

// Discriminated union — plus performant
const EventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
  z.object({ type: z.literal("keydown"), key: z.string() }),
  z.object({ type: z.literal("scroll"), offset: z.number() })
])

z.discriminatedUnion est plus rapide que z.union parce que Zod regarde d'abord le discriminant pour choisir le bon schema.

Optionnels et défauts

typescriptz.string().optional()         // string | undefined
z.string().nullable()         // string | null
z.string().nullish()          // string | null | undefined
z.string().default("default") // string (avec valeur par defaut)

Transformations

Zod peut transformer les donnees pendant la validation :

typescriptconst NumberFromString = z.string().transform(s => parseInt(s, 10))
const result = NumberFromString.parse("42") // type: number, valeur: 42

// Coercion integrée
const CoercedNumber = z.coerce.number()
CoercedNumber.parse("42")   // 42
CoercedNumber.parse(true)   // 1

Les transformations changent le type de sortie. z.infer reflete le type transforme.

Intégration avec une API

Pattern que j'utilise sur paltemps.fr :

typescript// schemas/user.ts
export const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "user"]).default("user")
})

export type CreateUserInput = z.infer<typeof CreateUserSchema>

// api/users.ts
app.post("/api/users", async (req) => {
  const result = CreateUserSchema.safeParse(req.body)

  if (!result.success) {
    return Response.json(
      { error: result.error.flatten() },
      { status: 400 }
    )
  }

  const user = await db.user.create({ data: result.data })
  return Response.json(user)
})

Le schema valide le body de la requête. Si c'est invalide, l'API retourne une erreur 400 avec les détails. Si c'est valide, result.data a le bon type.

Intégration avec un fetch client

typescriptasync function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  const json = await res.json()
  return UserSchema.parse(json) // valide + type
}

Si l'API change et que le format ne correspond plus, Zod lance une erreur explicite au lieu d'un undefined is not a function 10 appels plus tard.

.extend, .merge, .pick, .omit

Zod a les memes opérations que les utility types TypeScript :

typescriptconst BaseSchema = z.object({
  id: z.string().uuid(),
  createdAt: z.date(),
  updatedAt: z.date()
})

// Extend : ajouter des champs
const UserSchema = BaseSchema.extend({
  name: z.string(),
  email: z.string().email()
})

// Pick : garder certains champs
const UserSummary = UserSchema.pick({ name: true, email: true })

// Omit : retirer des champs
const CreateUser = UserSchema.omit({ id: true, createdAt: true, updatedAt: true })

// Partial : tout optionnel
const UpdateUser = CreateUser.partial()

Les types sont derives automatiquement. z.infer<typeof UpdateUser> donne { name?: string; email?: string }.


Résumé

  • Zod definit un schema qui est a la fois une validation runtime et un type TypeScript
  • z.infer<typeof Schema> derive le type du schema — un seul endroit a maintenir
  • .safeParse() retourne un résultat discrimine (success/error) au lieu de lancer
  • Les schemas supportent les objets imbriques, les unions, les transformations, les défauts
  • Valide aux frontieres (API, input, env) — a l'intérieur du code les types suffisent

Article précédent : 01 - tsconfig en profondeur

Article suivant : 03 - Variables d'environnement type-safe

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.