TypeScriptZodClean CodeDDD

Value Objects TypeScript : types métier avec Zod

Publié le

Dimitri Dumont
Dimitri Dumont

Développeur React & Next.js freelance

Une fonction calculateTotal prend un number en paramètre. Ce number représente-t-il un prix en euros, en centimes, TTC ou HT ?

TypeScript ne fait pas la distinction. Le compilateur accepte n'importe quel nombre, quelle que soit sa signification métier.

Ce manque de typage sémantique cause des bugs récurrents : prix négatifs acceptés en production, devises mélangées dans un calcul, poids en grammes additionnés à des poids en kilogrammes. Le problème ne vient pas de TypeScript, mais de l'utilisation de primitives pour représenter des concepts métier qui possèdent leurs propres règles.

Les Value Objects résolvent ce problème : validation centralisée, règles métier encapsulées, bugs détectés à la création plutôt qu'en production.

Dans cet article :

  • Pourquoi les primitives posent problème dans le code métier
  • Définition précise d'un Value Object selon le Domain-Driven Design
  • Le pattern Zod schema + factory function
  • Deux exemples métier complets : Price et Weight
  • Sérialisation et persistance

Le problème des primitives

Les primitives ne portent pas de sens métier

Ce code compile sans erreur :

src/services/order-service.ts
const calculateTotal = (price: number, quantity: number, discount: number): number => {
  return price * quantity - discount
}
 
calculateTotal(5, 99.99, 10)

Le problème : price et quantity ont été inversés. TypeScript ne signale rien car ce sont tous des number. Le compilateur valide, le bug passe en production.

Un number peut représenter un prix, une quantité, un pourcentage, un identifiant ou une température. TypeScript ne distingue pas ces concepts.

Les règles métier sont dispersées

Quand les primitives ne portent pas leurs règles, la validation se retrouve dupliquée :

src/services/product-service.ts
const createProduct = (price: number) => {
  if (price < 0) throw new Error('Prix négatif')
}
 
const updatePrice = (productId: string, newPrice: number) => {
  if (newPrice < 0) throw new Error('Prix négatif')
}
 
const applyDiscount = (price: number, percent: number) => {
  const discounted = price * (1 - percent / 100)
  if (discounted < 0) throw new Error('Prix négatif')
}

La règle "prix positif" apparaît trois fois dans un seul fichier. Si cette règle évolue (prix minimum de 0.01€), chaque occurrence doit être modifiée. Un oubli génère un bug.

Qu'est-ce qu'un Value Object ?

Définition selon le Domain-Driven Design

Eric Evans définit un Value Object dans Domain-Driven Design (2003) comme un objet qui "importe uniquement par la combinaison de ses attributs". Deux Value Objects avec les mêmes valeurs d'attributs sont considérés comme égaux.

Selon Martin Fowler, les caractéristiques fondamentales sont :

  1. Absence d'identité : un Value Object n'a pas d'identifiant unique. Ce qui compte, c'est ce qu'il est, pas qui il est
  2. Égalité par valeur : deux instances avec des attributs identiques sont interchangeables
  3. Immuabilité : pour modifier un Value Object, on crée une nouvelle instance plutôt que de modifier l'existante

Cette immuabilité évite les aliasing bugs qui surviennent quand plusieurs références pointent vers un objet mutable.

Différence avec une Entity

Une Entity possède une identité qui persiste dans le temps. Un utilisateur reste le même utilisateur même si son email change. Un Value Object n'a pas cette continuité : deux Price(100, 'EUR') sont identiques et interchangeables.

CaractéristiqueEntityValue Object
IdentitéIdentifiant uniqueDéfini par ses attributs
ÉgalitéPar identifiantPar valeur des attributs
MutabilitéPeut évoluerImmuable
ExempleUser, OrderPrice, Weight, Email

Value Object vs primitive

PrimitiveValue Object
price: numberprice: Price (montant + devise + règles)
weight: numberweight: Weight (valeur + unité + conversions)
Aucune validationToujours valide par construction
Logique disperséeComportements encapsulés

Le pattern Zod + Factory

Structure

Le pattern combine trois éléments :

  1. Un schema Zod pour la validation
  2. Un type pour les données et comportements
  3. Une factory function comme unique point de création
src/domain/price.ts
import { z } from 'zod'
 
// positive() exclut 0 : utiliser nonnegative() si 0 est valide
const PriceSchema = z.object({
  amount: z.number().positive(),
  currency: z.enum(['EUR', 'USD']),
})
 
export type Price = {
  readonly amount: number
  readonly currency: 'EUR' | 'USD'
  format: () => string
  add: (other: Price) => Price
}
 
export const createPrice = (amount: number, currency: 'EUR' | 'USD'): Price => {
  const data = PriceSchema.parse({ amount, currency })
 
  return {
    amount: data.amount,
    currency: data.currency,
 
    format: () =>
      new Intl.NumberFormat('fr-FR', {
        style: 'currency',
        currency: data.currency,
      }).format(data.amount),
 
    add: (other) => {
      if (other.currency !== data.currency) {
        throw new Error(`Devises incompatibles : ${data.currency} et ${other.currency}`)
      }
      return createPrice(data.amount + other.amount, data.currency)
    },
  }
}

La factory createPrice est le seul moyen de créer un Price. Zod valide les données à chaque création. Si les données sont invalides, une erreur est levée immédiatement.

Utilisation

src/services/cart-service.ts
const price1 = createPrice(100, 'EUR')
const price2 = createPrice(50, 'EUR')
 
const total = price1.add(price2)
console.log(total.format()) // "150,00 €"
 
createPrice(-10, 'EUR') // ZodError: Number must be greater than 0
 
const usd = createPrice(100, 'USD')
price1.add(usd) // Error: Devises incompatibles : EUR et USD

Les règles sont centralisées. Les erreurs surviennent à la création, pas au milieu d'un calcul.

Pour éviter les erreurs de précision flottante (0.1 + 0.2 = 0.30000000000000004), stockez les montants en centimes (entiers) et convertissez uniquement à l'affichage. Cette approche est la norme dans les systèmes financiers : elle élimine les erreurs d'arrondi et simplifie les comparaisons d'égalité.

Exemple : Weight avec conversion d'unités

Un poids combine une valeur numérique et une unité. Additionner des grammes et des kilogrammes sans conversion est une erreur que le Value Object prévient :

src/domain/weight.ts
import { z } from 'zod'
 
const WeightSchema = z.object({
  value: z.number().positive(),
  unit: z.enum(['kg', 'g']),
})
 
export type Weight = {
  readonly value: number
  readonly unit: 'kg' | 'g'
  toKg: () => Weight
  add: (other: Weight) => Weight
  format: () => string
}
 
export const createWeight = (value: number, unit: 'kg' | 'g'): Weight => {
  const data = WeightSchema.parse({ value, unit })
 
  const toKgValue = (): number => {
    return data.unit === 'kg' ? data.value : data.value / 1000
  }
 
  return {
    value: data.value,
    unit: data.unit,
 
    toKg: () => createWeight(toKgValue(), 'kg'),
 
    add: (other) => {
      const totalKg = toKgValue() + other.toKg().value
      return createWeight(totalKg, 'kg')
    },
 
    format: () => `${data.value} ${data.unit}`,
  }
}
const w1 = createWeight(500, 'g')
const w2 = createWeight(1, 'kg')
const total = w1.add(w2)
console.log(total.format()) // "1.5 kg"

L'addition convertit automatiquement les unités.

Sérialisation et persistance

Le problème

Les Value Objects contiennent des méthodes. JSON ne sérialise pas les méthodes :

const price = createPrice(100, 'EUR')
const json = JSON.stringify(price)
// {"amount":100,"currency":"EUR"}
 
const parsed = JSON.parse(json)
parsed.format() // TypeError: parsed.format is not a function

Pattern toJSON / fromJSON

Ajoutez une méthode toJSON et une fonction fromJSON :

src/domain/price.ts
export const createPrice = (amount: number, currency: 'EUR' | 'USD'): Price => {
  const data = PriceSchema.parse({ amount, currency })
 
  return {
    amount: data.amount,
    currency: data.currency,
    format: () => /* ... */,
    add: (other) => /* ... */,
    toJSON: () => ({ amount: data.amount, currency: data.currency }),
  }
}
 
export const priceFromJSON = (json: unknown): Price => {
  const data = PriceSchema.parse(json)
  return createPrice(data.amount, data.currency)
}
const price = createPrice(100, 'EUR')
const json = JSON.stringify(price.toJSON())
 
const restored = priceFromJSON(JSON.parse(json))
restored.format() // "100,00 €"

Intégration avec un ORM

Le repository traduit entre les primitives de la base de données et les Value Objects du domaine :

src/repositories/product-repository.ts
import { createPrice } from '@/domain/price'
import { createWeight } from '@/domain/weight'
import { prisma } from '@/lib/prisma'
 
export const productRepository = {
  findById: async (id: string) => {
    const row = await prisma.product.findUnique({ where: { id } })
    if (!row) return null
 
    return {
      id: row.id,
      name: row.name,
      price: createPrice(row.priceAmount, row.priceCurrency as 'EUR' | 'USD'),
      weight: createWeight(row.weightValue, row.weightUnit as 'kg' | 'g'),
    }
  },
 
  save: async (product: { name: string; price: Price; weight: Weight }) => {
    await prisma.product.create({
      data: {
        name: product.name,
        priceAmount: product.price.amount,
        priceCurrency: product.price.currency,
        weightValue: product.weight.value,
        weightUnit: product.weight.unit,
      },
    })
  },
}

Le domaine manipule des Value Objects. Le repository gère la conversion.

Les erreurs à éviter

Sur-engineering

Ne créez pas un Value Object pour chaque primitive :

// ❌ Excessif : Value Object pour un booléen simple
type IsActive = { value: boolean; toggle: () => IsActive }
 
// ✅ Un booléen suffit
type User = { isActive: boolean }

Un Value Object se justifie si la valeur possède des règles métier ou des comportements significatifs.

Validation tardive

// ❌ Utilisation de la primitive avant validation
const processOrder = (amount: number, currency: string) => {
  doSomething(amount)
  const price = createPrice(amount, currency as 'EUR' | 'USD')
}
 
// ✅ Validation immédiate
const processOrder = (amount: number, currency: string) => {
  const price = createPrice(amount, currency as 'EUR' | 'USD')
  doSomething(price)
}

Oublier la sérialisation

// ❌ Stockage direct du Value Object
localStorage.setItem('cart', JSON.stringify(cartItems))
 
// ✅ Extraction des données puis reconstruction
localStorage.setItem('cart', JSON.stringify(cartItems.map(item => ({
  name: item.name,
  price: item.price.toJSON(),
  quantity: item.quantity,
}))))

FAQ

Quelle différence entre un type alias et un Value Object ?

Un type alias (type Price = number) n'ajoute aucune protection au runtime. TypeScript le traite comme un number. Un Value Object encapsule la valeur, la valide à la création et expose des comportements. La factory Zod garantit qu'aucune instance invalide ne peut exister.

Faut-il créer un Value Object pour chaque primitive ?

Non. Un Value Object se justifie quand la valeur possède des règles métier (prix strictement positif, poids non nul) ou des comportements (formatage, conversion d'unités, opérations arithmétiques). Un isActive: boolean sans logique associée ne nécessite pas de Value Object.

Comment gérer la sérialisation JSON ?

Les méthodes disparaissent après JSON.stringify. Implémentez une méthode toJSON() qui extrait les données, et une fonction fromJSON() qui reconstruit le Value Object. Zod valide à nouveau les données lors de la reconstruction.

Les Value Objects impactent-ils les performances ?

L'impact est négligeable. La validation Zod prend quelques microsecondes. La création d'objets est optimisée par V8. Sauf cas de création massive (millions d'instances par seconde), l'impact reste invisible.

Pourquoi des factory functions plutôt que des classes ?

Les factory functions sont plus légères et s'intègrent mieux dans une approche fonctionnelle. Pas de this, pas de new, pas d'héritage. Les classes restent une alternative valide selon les préférences de l'équipe.

Conclusion

Points clés

  1. Les primitives ne portent pas de sens métier : un number ne garantit rien sur les règles qu'il doit respecter
  2. Value Object = objet défini par ses attributs, immuable, sans identité propre
  3. Zod + factory = validation à la création, instance toujours valide
  4. Sérialisation : toujours reconstruire le Value Object après désérialisation

Quand créer un Value Object ?

SignalExemple
La valeur a des règles métierPrix strictement positif
La valeur a des comportementsFormatage monétaire, conversion d'unités
La valeur combine plusieurs attributsPrix = montant + devise
La même validation est dupliquéeif (price < 0) répété dans le code

Checklist

  • Identifier les primitives représentant des concepts métier
  • Créer un schema Zod pour chaque concept
  • Implémenter une factory function comme unique point de création
  • Ajouter les comportements nécessaires
  • Gérer la sérialisation (toJSON/fromJSON)
  • Mapper dans les repositories

Pour aller plus loin

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