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é ?