Lorsque vous modélisez des entités avec TypeScript, il est très courant d’obtenir une interface comme celle-ci :
interface User {
id: number
username: string
}
interface Order {
id: number
userId: number
title: string
year: number
month: number
day: number
amount: { currency: 'EUR' | 'USD', value: number }
}
Le Problème
Les types des propriétés n’ont pas de signification sémantique. En termes de types, User.id
, Order.id
, Order.year
, etc. sont les mêmes : un nombre, et en tant que nombre, ils sont interchangeables, mais sémantiquement, ils ne le sont pas.
En suivant l’exemple précédent, nous pouvons avoir un ensemble de fonctions qui effectuent des actions sur les entités. Par exemple :
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
Ces fonctions accepteront n’importe quel nombre dans n’importe quel argument, peu importe la signification sémantique du nombre. Par exemple :
const id = getUserId()
deleteOrder(id)
Évidemment, c’est une grosse erreur, et il pourrait sembler facile d’éviter de lire le code, mais le code n’est pas toujours aussi simple que l’exemple.
Il en va de même pour getOrdersFiltered
: nous pouvons échanger les valeurs du jour et du mois, et nous n’obtiendrons aucun avertissement ou erreur. Les erreurs se produiront si le jour est supérieur à 12, mais il est évident que le résultat ne sera pas celui attendu.
La Solution
Les règles de la callisthénie des objets fournissent une solution à cela : envelopper tous les primitifs et les chaînes (anti-modèle d’obsession des primitifs liés). La règle est d’envelopper les primitifs dans un objet qui représente une signification sémantique (DDD décrit cela comme ValueObjects
).
Mais avec TypeScript, nous n’avons pas besoin d’utiliser des classes ou des objets pour cela : nous pouvons utiliser le système de types pour garantir qu’un nombre qui représente quelque chose de différent d’une année ne peut pas être utilisé à la place d’une année.
Types Marqués
Ce modèle utilise l’extensibilité des types pour ajouter une propriété qui garantit la signification sémantique :
type Year = number & { __brand: 'year' }
Cette ligne simple crée un nouveau type qui peut fonctionner comme un nombre — mais n’est pas un nombre, c’est une année.
const year = 2012 as Year
function age(year: Year): number { //... }
age(2012) // ❌ IDE will show an error as 2012 is not a Year
age(year) // ✅
Généraliser la solution
Pour éviter d’écrire un type par type de marque, nous pouvons créer un type utilitaire comme :
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
Qui utilise un symbole unique comme nom de propriété de marque pour éviter les conflits avec vos propriétés et obtient le type original et la marque en tant que paramètres génériques.
Avec cela, nous pouvons refactoriser nos modèles et fonctions comme suit :
type UserId = Branded<number, 'UserId'>
type OrderId = Branded<number, 'OrderId'>
type Year = Branded<number, 'Year'>
type Month = Branded<number, 'Month'>
type Day = Branded<number, 'Day'>
type Amount = Branded<{ currency: 'EUR' | 'USD', value: number}, 'Amount'>
interface User {
id: UserId
username: string
}
interface Order {
id: OrderId
userId: UserId
title: string
year: Year
month: Month
day: Day
amount: Amount
}
function getOrdersFiltered(userId: UserId, year: Year, month: Month, day: Day, amount: Amount) { // ...}
function deleteOrder(id: OrderId) { // ... }
Maintenant, dans cet exemple, l’IDE affichera une erreur car id
est un UserId
et deleteOrder
attend un OrderId
.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
Compromis
Comme petit compromis, vous devrez utiliser X
comme Brand
. Par exemple, const year = 2012 as Year
lorsque vous créez une nouvelle valeur à partir d’un primitif, mais c’est l’équivalent de new Year(2012)
si vous utilisez des objets de valeur. Vous pouvez fournir une fonction qui fonctionne comme une sorte de « constructeur » :
function year(year: number): Year {
return year as Year
}
Validation avec des types de marque
Les types de marque sont également utiles pour garantir que les données sont valides car vous pouvez avoir des types spécifiques pour les données validées, et vous pouvez faire confiance à l’utilisateur d’avoir été validé simplement en utilisant des types :
type User = { id: UserId, email: Email}
type ValidUser = Readonly<Brand<User, 'ValidUser'>>
function validateUser(user: User): ValidUser {
// Checks if user is in the database
if (!/* logic to check the user is in database */) {
throw new InvalidUser()
}
return user as ValidUser
}
// We can not pass just a User, needs to be a ValidUser
function doSomethingWithAValidUser(user: ValidUser) {
}
Readonly
n’est pas obligatoire, mais pour être sûr que votre code ne modifiera pas les données après les avoir validées, il est fortement recommandé.
Récapitulatif
Les types de marque sont une solution simple qui comprend les éléments suivants :
- Améliore la lisibilité du code : Rend plus clair quel valeur doit être utilisée dans chaque argument
- Fiabilité : Aide à éviter les erreurs dans le code qui peuvent être difficiles à détecter ; désormais, l’IDE (et la vérification de type) nous aide à détecter si la valeur est au bon endroit
- Validation des données : Vous pouvez utiliser des types de marque pour garantir que les données sont valides.
Vous pouvez considérer les types de marque comme une sorte de version de ValueObjects
mais sans utiliser de classes — juste des types et des fonctions.
Profitez de la puissance des types !
Source:
https://dzone.com/articles/branded-types-in-typescript