Découvrez les types 'branded' de TypeScript, une technique puissante pour obtenir un typage nominal dans un système de types structurel. Apprenez à améliorer la sécurité des types et la clarté du code.
Types 'Branded' TypeScript : le Typage Nominal dans un Système Structurel
Le système de types structurel de TypeScript offre de la flexibilité mais peut parfois conduire à des comportements inattendus. Les types 'branded' (ou types marqués) fournissent un moyen d'appliquer un typage nominal, améliorant la sécurité des types et la clarté du code. Cet article explore les types 'branded' en détail, en fournissant des exemples pratiques et les meilleures pratiques pour leur mise en œuvre.
Comprendre le Typage Structurel vs. Nominal
Avant de plonger dans les types 'branded', clarifions la différence entre le typage structurel et le typage nominal.
Typage Structurel (Duck Typing)
Dans un système de types structurel, deux types sont considérés comme compatibles s'ils ont la même structure (c'est-à-dire les mêmes propriétés avec les mêmes types). TypeScript utilise le typage structurel. Considérez cet exemple :
interface Point {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Valide en TypeScript
console.log(vector.x); // Sortie : 10
Même si Point
et Vector
sont déclarés comme des types distincts, TypeScript autorise l'assignation d'un objet Point
à une variable Vector
car ils partagent la même structure. Cela peut être pratique, mais peut aussi entraîner des erreurs si vous avez besoin de distinguer des types logiquement différents qui ont fortuitement la même forme. Par exemple, des coordonnées de latitude/longitude qui pourraient accidentellement correspondre à des coordonnées de pixels d'écran.
Typage Nominal
Dans un système de types nominal, les types sont considérés comme compatibles uniquement s'ils ont le même nom. Même si deux types ont la même structure, ils sont traités comme distincts s'ils ont des noms différents. Des langages comme Java et C# utilisent le typage nominal.
La Nécessité des Types 'Branded'
Le typage structurel de TypeScript peut être problématique lorsque vous devez vous assurer qu'une valeur appartient à un type spécifique, indépendamment de sa structure. Par exemple, imaginez la représentation des devises. Vous pourriez avoir des types différents pour USD et EUR, mais tous deux pourraient être représentés par des nombres. Sans un mécanisme pour les distinguer, vous pourriez accidentellement effectuer des opérations sur la mauvaise devise.
Les types 'branded' résolvent ce problème en vous permettant de créer des types distincts qui sont structurellement similaires mais traités comme différents par le système de types. Cela améliore la sécurité des types et prévient les erreurs qui pourraient autrement passer inaperçues.
Implémenter les Types 'Branded' en TypeScript
Les types 'branded' sont implémentés en utilisant des types d'intersection et un symbole unique ou un littéral de chaîne. L'idée est d'ajouter une 'marque' (brand) à un type pour le distinguer des autres types ayant la même structure.
Utilisation des Symboles (Recommandé)
L'utilisation de symboles pour le marquage est généralement préférée car les symboles sont garantis d'être uniques.
const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };
const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD :", totalUSD);
// Décommenter la ligne suivante provoquera une erreur de type
// const invalidOperation = addUSD(usd1, eur1);
Dans cet exemple, USD
et EUR
sont des types marqués basés sur le type number
. Le unique symbol
garantit que ces types sont distincts. Les fonctions createUSD
et createEUR
sont utilisées pour créer des valeurs de ces types, et la fonction addUSD
n'accepte que des valeurs USD
. Tenter d'ajouter une valeur EUR
à une valeur USD
entraînera une erreur de type.
Utilisation des Littéraux de Chaîne
Vous pouvez également utiliser des littéraux de chaîne pour le marquage, bien que cette approche soit moins robuste que l'utilisation de symboles car les littéraux de chaîne ne sont pas garantis d'être uniques.
type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD :", totalUSD);
// Décommenter la ligne suivante provoquera une erreur de type
// const invalidOperation = addUSD(usd1, eur1);
Cet exemple atteint le même résultat que le précédent, mais en utilisant des littéraux de chaîne au lieu de symboles. Bien que plus simple, il est important de s'assurer que les littéraux de chaîne utilisés pour le marquage sont uniques dans votre base de code.
Exemples Pratiques et Cas d'Utilisation
Les types 'branded' peuvent être appliqués à divers scénarios où vous devez renforcer la sécurité des types au-delà de la compatibilité structurelle.
Identifiants (IDs)
Considérez un système avec différents types d'identifiants, tels que UserID
, ProductID
, et OrderID
. Tous ces identifiants pourraient être représentés par des nombres ou des chaînes de caractères, mais vous voulez éviter de mélanger accidentellement différents types d'ID.
const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };
const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };
function getUser(id: UserID): { name: string } {
// ... récupérer les données de l'utilisateur
return { name: "Alice" };
}
function getProduct(id: ProductID): { name: string, price: number } {
// ... récupérer les données du produit
return { name: "Exemple de Produit", price: 25 };
}
function createUserID(id: string): UserID {
return id as UserID;
}
function createProductID(id: string): ProductID {
return id as ProductID;
}
const userID = createUserID('user123');
const productID = createProductID('product456');
const user = getUser(userID);
const product = getProduct(productID);
console.log("Utilisateur :", user);
console.log("Produit :", product);
// Décommenter la ligne suivante provoquera une erreur de type
// const invalidCall = getUser(productID);
Cet exemple démontre comment les types 'branded' peuvent empêcher de passer un ProductID
à une fonction qui attend un UserID
, améliorant ainsi la sécurité des types.
Valeurs Spécifiques au Domaine
Les types 'branded' peuvent aussi être utiles pour représenter des valeurs spécifiques à un domaine avec des contraintes. Par exemple, vous pourriez avoir un type pour les pourcentages qui doivent toujours être compris entre 0 et 100.
const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };
function createPercentage(value: number): Percentage {
if (value < 0 || value > 100) {
throw new Error('Le pourcentage doit être compris entre 0 et 100');
}
return value as Percentage;
}
function applyDiscount(price: number, discount: Percentage): number {
return price * (1 - discount / 100);
}
try {
const discount = createPercentage(20);
const discountedPrice = applyDiscount(100, discount);
console.log("Prix réduit :", discountedPrice);
// Décommenter la ligne suivante provoquera une erreur à l'exécution
// const invalidPercentage = createPercentage(120);
} catch (error) {
console.error(error);
}
Cet exemple montre comment appliquer une contrainte sur la valeur d'un type marqué à l'exécution. Bien que le système de types ne puisse pas garantir qu'une valeur Percentage
soit toujours entre 0 et 100, la fonction createPercentage
peut imposer cette contrainte à l'exécution. Vous pouvez également utiliser des bibliothèques comme io-ts pour imposer la validation à l'exécution des types marqués.
Représentations de Date et d'Heure
Travailler avec les dates et les heures peut être délicat en raison des différents formats et fuseaux horaires. Les types 'branded' peuvent aider à différencier les différentes représentations de date et d'heure.
const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };
const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };
function createUTCDate(dateString: string): UTCDate {
// Valider que la chaîne de date est au format UTC (par ex., ISO 8601 avec Z)
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
throw new Error('Format de date UTC invalide');
}
return dateString as UTCDate;
}
function createLocalDate(dateString: string): LocalDate {
// Valider que la chaîne de date est au format de date locale (par ex., AAAA-MM-JJ)
if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
throw new Error('Format de date locale invalide');
}
return dateString as LocalDate;
}
function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
// Effectuer la conversion de fuseau horaire
const date = new Date(utcDate);
const localDateString = date.toLocaleDateString();
return createLocalDate(localDateString);
}
try {
const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
const localDate = convertUTCDateToLocalDate(utcDate);
console.log("Date UTC :", utcDate);
console.log("Date Locale :", localDate);
} catch (error) {
console.error(error);
}
Cet exemple différencie les dates UTC des dates locales, garantissant que vous travaillez avec la bonne représentation de date et d'heure dans différentes parties de votre application. La validation à l'exécution garantit que seules les chaînes de date correctement formatées peuvent se voir attribuer ces types.
Meilleures Pratiques pour l'Utilisation des Types 'Branded'
Pour utiliser efficacement les types 'branded' en TypeScript, considérez les meilleures pratiques suivantes :
- Utilisez des Symboles pour le Marquage : Les symboles offrent la plus forte garantie d'unicité, réduisant le risque d'erreurs de type.
- Créez des Fonctions d'Aide (Helpers) : Utilisez des fonctions d'aide pour créer des valeurs de types marqués. Cela fournit un point central pour la validation et garantit la cohérence.
- Appliquez la Validation à l'Exécution : Bien que les types 'branded' améliorent la sécurité des types, ils n'empêchent pas l'assignation de valeurs incorrectes à l'exécution. Utilisez la validation à l'exécution pour imposer des contraintes.
- Documentez les Types 'Branded' : Documentez clairement le but et les contraintes de chaque type marqué pour améliorer la maintenabilité du code.
- Considérez les Implications sur la Performance : Les types 'branded' introduisent une légère surcharge due au type d'intersection et à la nécessité de fonctions d'aide. Considérez l'impact sur la performance dans les sections critiques de votre code.
Avantages des Types 'Branded'
- Sécurité des Types Améliorée : Empêche le mélange accidentel de types structurellement similaires mais logiquement différents.
- Clarté du Code Améliorée : Rend le code plus lisible et facile à comprendre en différenciant explicitement les types.
- Réduction des Erreurs : Détecte les erreurs potentielles à la compilation, réduisant le risque de bogues à l'exécution.
- Maintenabilité Accrue : Rend le code plus facile à maintenir et à refactoriser en fournissant une séparation claire des préoccupations.
Inconvénients des Types 'Branded'
- Complexité Accrue : Ajoute de la complexité à la base de code, en particulier lorsqu'on traite avec de nombreux types marqués.
- Surcharge à l'Exécution : Introduit une légère surcharge à l'exécution en raison de la nécessité de fonctions d'aide et de validation.
- Potentiel de Code Répétitif (Boilerplate) : Peut conduire à du code répétitif, notamment lors de la création et de la validation des types marqués.
Alternatives aux Types 'Branded'
Bien que les types 'branded' soient une technique puissante pour obtenir un typage nominal en TypeScript, il existe des approches alternatives que vous pourriez envisager.
Types Opaques
Les types opaques sont similaires aux types 'branded' mais fournissent une manière plus explicite de masquer le type sous-jacent. TypeScript ne prend pas en charge nativement les types opaques, mais vous pouvez les simuler en utilisant des modules et des symboles privés.
Classes
L'utilisation de classes peut fournir une approche plus orientée objet pour définir des types distincts. Bien que les classes soient typées structurellement en TypeScript, elles offrent une séparation plus claire des préoccupations et peuvent être utilisées pour imposer des contraintes via des méthodes.
Bibliothèques comme `io-ts` ou `zod`
Ces bibliothèques fournissent une validation de type sophistiquée à l'exécution et peuvent être combinées avec les types 'branded' pour garantir la sécurité à la fois à la compilation et à l'exécution.
Conclusion
Les types 'branded' de TypeScript sont un outil précieux pour améliorer la sécurité des types et la clarté du code dans un système de types structurel. En ajoutant une 'marque' à un type, vous pouvez imposer un typage nominal et empêcher le mélange accidentel de types structurellement similaires mais logiquement différents. Bien que les types 'branded' introduisent une certaine complexité et une surcharge, les avantages d'une sécurité de type améliorée et d'une meilleure maintenabilité du code l'emportent souvent sur les inconvénients. Envisagez d'utiliser des types 'branded' dans des scénarios où vous devez vous assurer qu'une valeur appartient à un type spécifique, indépendamment de sa structure.
En comprenant les principes du typage structurel et nominal, et en appliquant les meilleures pratiques décrites dans cet article, vous pouvez exploiter efficacement les types 'branded' pour écrire un code TypeScript plus robuste et maintenable. De la représentation des devises et des identifiants à l'application de contraintes spécifiques au domaine, les types 'branded' fournissent un mécanisme flexible et puissant pour améliorer la sécurité des types dans vos projets.
Lorsque vous travaillez avec TypeScript, explorez les différentes techniques et bibliothèques disponibles pour la validation et l'application des types. Envisagez d'utiliser les types 'branded' en conjonction avec des bibliothèques de validation à l'exécution comme io-ts
ou zod
pour obtenir une approche complète de la sécurité des types.