Value Objects — 01 — Implémenter un Value Object en TypeScript et Python

Guide pratique pour créer des Value Objects solides : factory method, validation, immuabilité, equals, sérialisation. Patterns complets TypeScript et Python.

01 — Implémenter un Value Object en TypeScript et Python

Ce que tu vas apprendre

  • La structure complète d'un Value Object (constructeur privé, factory, equals, serialize)
  • Pourquoi utiliser une factory method plutôt qu'un constructeur public
  • Comment gérer les erreurs de validation : exceptions vs Result type
  • Les patterns TypeScript (readonly, private constructor) et Python (@dataclass(frozen=True))

Prérequis


La structure de base

Un Value Object suit toujours la même structure :

1. Constructeur privé  — empêche la création directe non validée
2. Factory method      — point d'entrée unique avec validation
3. Méthode equals      — égalité par valeur
4. Méthode toString    — extraction de la valeur brute
5. Sérialisation       — conversion pour la BDD ou l'API

Pourquoi un constructeur privé ?

Si le constructeur est public, n'importe qui peut créer new Email("") ou new Money(-50, "YOLO") sans passer par la validation. Le constructeur privé force l'utilisation de la factory method. La validation est garantie à chaque création.


Pattern TypeScript complet

typescriptclass Email {
  // La valeur interne est privée et readonly — aucune modification possible
  private readonly value: string;

  // Constructeur privé — on ne peut pas faire `new Email()` depuis l'extérieur
  private constructor(value: string) {
    this.value = value;
  }

  // Factory method — seul point d'entrée, valide avant de construire
  static create(raw: string): Email {
    const trimmed = raw.trim().toLowerCase();

    if (!trimmed) {
      throw new Error("L'email ne peut pas être vide");
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
      throw new Error(`"${raw}" n'est pas une adresse email valide`);
    }

    return new Email(trimmed);
  }

  // Égalité par valeur
  equals(other: Email): boolean {
    return this.value === other.value;
  }

  // Extraction de la valeur brute
  toString(): string {
    return this.value;
  }
}

Utilisation :

typescriptconst a = Email.create("Alice@Exemple.fr");
const b = Email.create("alice@exemple.fr");

console.log(a.equals(b));   // true — normalisé à la création
console.log(a.toString());  // "alice@exemple.fr"

Email.create("");            // Error: L'email ne peut pas être vide
Email.create("pas-un-email"); // Error: "pas-un-email" n'est pas une adresse email valide

Pattern Python complet

Python a deux approches principales.

Approche 1 : `@dataclass(frozen=True)` (recommandée)

pythonimport re
from dataclasses import dataclass

@dataclass(frozen=True)
class Email:
    value: str

    def __post_init__(self) -> None:
        normalized = self.value.strip().lower()

        if not normalized:
            raise ValueError("L'email ne peut pas être vide")
        if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+{{body}}#x27;, normalized):
            raise ValueError(f'"{self.value}" n\'est pas une adresse email valide')

        # frozen=True interdit les setters normaux — on contourne pour la normalisation initiale
        object.__setattr__(self, 'value', normalized)

    def __str__(self) -> str:
        return self.value

frozen=True génère automatiquement __hash__ et rend l'objet immuable. Toute tentative de modification lève une FrozenInstanceError.

Approche 2 : classe manuelle (plus de contrôle)

pythonclass Email:
    __slots__ = ('_value',)

    def __init__(self, raw: str) -> None:
        normalized = raw.strip().lower()

        if not normalized:
            raise ValueError("L'email ne peut pas être vide")
        if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+{{body}}#x27;, normalized):
            raise ValueError(f'"{raw}" n\'est pas une adresse email valide')

        self._value = normalized

    @property
    def value(self) -> str:
        return self._value

    # Pas de setter — l'objet est immuable

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Email):
            return NotImplemented
        return self._value == other._value

    def __hash__(self) -> int:
        return hash(self._value)

    def __str__(self) -> str:
        return self._value

    def __repr__(self) -> str:
        return f"Email('{self._value}')"

__slots__ interdit l'ajout d'attributs dynamiques et réduit la consommation mémoire. La propriété value est en lecture seule.


Gérer les erreurs de validation

Approche 1 : lever une exception

typescript// Direct et idiomatique
const email = Email.create("pas-un-email"); // lève une Error

Adapté quand la valeur invalide est une erreur de programmation (données passées par du code interne non validées).

Approche 2 : Result type (sans exceptions)

typescripttype Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

class Email {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static tryCreate(raw: string): Result<Email> {
    const trimmed = raw.trim().toLowerCase();

    if (!trimmed) {
      return { ok: false, error: "L'email ne peut pas être vide" };
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
      return { ok: false, error: `"${raw}" n'est pas un email valide` };
    }

    return { ok: true, value: new Email(trimmed) };
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

// Utilisation
const result = Email.tryCreate(userInput);
if (!result.ok) {
  console.error(result.error);
} else {
  sendEmail(result.value);
}

En Python avec un tuple (valeur, erreur) :

pythonfrom typing import Optional, Tuple

@dataclass(frozen=True)
class Email:
    value: str

    def __post_init__(self) -> None:
        normalized = self.value.strip().lower()
        if not normalized:
            raise ValueError("L'email ne peut pas être vide")
        if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+{{body}}#x27;, normalized):
            raise ValueError(f'"{self.value}" n\'est pas un email valide')
        object.__setattr__(self, 'value', normalized)

    @classmethod
    def try_create(cls, raw: str) -> Tuple[Optional['Email'], Optional[str]]:
        try:
            return cls(raw), None
        except ValueError as e:
            return None, str(e)

# Utilisation
email, error = Email.try_create(user_input)
if error:
    print(f"Erreur : {error}")
else:
    send_email(email)

Exceptions pour les erreurs de programmation, Result pour les entrées utilisateur.

Données internes qui ne devraient jamais être invalides → exception. Saisie utilisateur → Result ou tuple d'erreur explicite.


Sérialisation et désérialisation

Les Value Objects doivent pouvoir se convertir en primitives pour la persistance et le transport réseau.

typescriptclass Money {
  private constructor(
    readonly amount: number,
    readonly currency: string
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error("Montant négatif interdit");
    if (!["EUR", "USD", "GBP"].includes(currency)) {
      throw new Error(`Devise inconnue : ${currency}`);
    }
    return new Money(amount, currency);
  }

  // Vers JSON (BDD ou API)
  toJSON(): { amount: number; currency: string } {
    return { amount: this.amount, currency: this.currency };
  }

  // Depuis JSON (lecture BDD ou API)
  static fromJSON(data: {
    amount: number;
    currency: string;
  }): Money {
    return Money.create(data.amount, data.currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Devises incompatibles");
    }
    return Money.create(this.amount + other.amount, this.currency);
  }

  equals(other: Money): boolean {
    return (
      this.amount === other.amount &&
      this.currency === other.currency
    );
  }
}

const price = Money.create(1999, "EUR");
const json = price.toJSON();
// { amount: 1999, currency: "EUR" }

const restored = Money.fromJSON(json);
console.log(price.equals(restored)); // true

Ce que fait ce code

toJSON() extrait les primitives pour la persistance. fromJSON() recrée le VO depuis la BDD ou l'API — la validation s'exécute à nouveau, ce qui garantit que les données stockées sont toujours valides à la lecture.

amount est en centimes (entier) pour éviter les problèmes de flottants (0.1 + 0.2 = 0.30000000000000004).

En Python :

pythonfrom dataclasses import dataclass, asdict
from typing import Dict, Any

@dataclass(frozen=True)
class Money:
    amount: int      # en centimes — pas de flottants
    currency: str

    VALID_CURRENCIES = frozenset({"EUR", "USD", "GBP"})

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError("Montant négatif interdit")
        if self.currency not in self.VALID_CURRENCIES:
            raise ValueError(f"Devise inconnue : {self.currency}")

    def to_dict(self) -> Dict[str, Any]:
        return {"amount": self.amount, "currency": self.currency}

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Money':
        return cls(amount=data["amount"], currency=data["currency"])

    def add(self, other: 'Money') -> 'Money':
        if self.currency != other.currency:
            raise ValueError("Devises incompatibles")
        return Money(amount=self.amount + other.amount, currency=self.currency)

# Persistance
price = Money(amount=1999, currency="EUR")
data = price.to_dict()             # {"amount": 1999, "currency": "EUR"}
restored = Money.from_dict(data)
print(price == restored)           # True

Récapitulatif des patterns

Élément TypeScript Python
Constructeur privé private constructor __init__ + factory ou __post_init__
Immuabilité readonly + private frozen=True ou __slots__ sans setter
Égalité .equals() custom __eq__ (auto avec @dataclass)
Hash à implémenter si besoin __hash__ (auto avec frozen=True)
Sérialisation .toJSON() / .fromJSON() .to_dict() / .from_dict()

Étape suivante : 02 — Value Objects vs Entités — quand un objet métier doit-il avoir une identité ?

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