Type Branding & Flavoring : Rendez votre code TypeScript plus lisible et plus robuste

Motivations

Le système de types de TypeScript est structurel et c'est l'un de ses principaux avantages. Cette caractéristique offre de nombreux outils puissants pour rendre les états non valides irreprésentables, permettant ainsi de détecter des bugs potentiels à la compilation et non au moment de l'exécution.

Cependant, ce système n'est pas toujours suffisant. Il existe par exemple des cas d'utilisation dans le monde réel où il est souhaitable que deux variables soient différenciées parce qu'elles ont un nom de type différent, même si elles ont strictement la même structure.

Exemple :

type PostId = number; type CommentId = number; const postId: PostId = post.id; const commentId: CommentId = postId; // OK

Il est possible d'assigner à la variable commentId le contenu de la variable postId, après tout, ce sont tous les deux des number si l'on regarde la définition de leur type. Pour autant, veut-on vraiment que cela soit possible ?

Du point de vue métier de notre application, cela n'a pas vraiment de sens. Un post de blog n'est pas la même chose qu'un commentaire. Cela tombe bien ! C'est exactement ce type de contrainte métier que le type branding nous permet de mettre en place.

Branding

Définition

Le concept de branding consiste à ajouter un champ distinctif à notre type pour le différencier des autres. Ce champ sera uniquement utile au compilateur TypeScript pour déterminer statiquement si deux types sont compatibles ou non.

Voici notre même exemple utilisant le type branding :

type PostId = number & { __brand: 'PostId' }; type CommentId = number & { __brand: 'CommentId' }; const value = 1 as PostId; const postId: PostId = value; // OK const commentId: CommentId = value; // Erreur

Bien que l'assignation d'un type PostId à un type CommentId ne pose aucun problème au runtime, elle génère désormais une erreur de compilation et évite des erreurs métier.

Il est commun de définir un type générique permettant de générer des types brandés :

type Brand<T, U> = T & { __brand: U }; type PostId = Brand<number, 'PostId'>; type CommentId = Brand<number, 'CommentId'>;

Note : un type tel que Brand peut exister car l'intersection entre number (ici) et un objet JS est permise par JavaScript. Dans la plupart des autres languages typés, une telle intersection équivaudrait à "never" et n'aurait pas de sens.

Limitations

  • Le changement d'un type en un type brandé requiert de le caster manuellement ;

  • Il est possible de lire la propriété __brand ;

  • Il n'y a pas de conversion implicite possible, par exemple :

type Post = Brand<{ author: string; content: string; }, 'Post'>; const createPost = (post: Post) => { ... }; createPost({ author: 'matthieu', content: 'Hello world!' }); // Erreur

Flavoring

Définition

Le flavoring est similaire en tout point au branding, au détail près que la propriété __brand est rendue optionnelle. Cette technique nous permet d'avoir une conversion implicite pour les types et les objets :

type Flavor<T, U> = T & { __flavor?: U }; type Post = Flavor<{ author: string; content: string; }, 'Post'>; type PostComment = Flavor<{ author: string; content: string; }, 'PostComment'>; const createPost = (post: Post) => { ... }; createPost({ author: 'matthieu', content: 'Hello world!' }); // OK const comment: PostComment = { author: 'matthieu', content: 'Hello world!' }; createPost(comment); // Erreur

Malgré cela, on remarque que l'on a tout de même conservé l'incompatibilité entre différents types ayant la même structure. C'est également vrai pour les types primitifs :

type Flavor<T, U> = T & { __flavor?: U }; type PostId = Flavor<number, 'PostId'> type CommentId = Flavor<number, 'CommentId'> const postId: PostId = 1; const commentId: CommentId = postId; // Erreur

Limitations

  • Il est toujours possible de lire la propriété __flavor ;

  • Moins strict que le branding, la conversion implicite peut mener à des erreurs et doit être utilisée à bon escient

Conclusion

S'il est communément admis qu'il est préférable d'utiliser le branding pour les types primitifs, le flavoring peut être préférable pour les objets afin de tirer bénéfice de la conversion implicite. On peut utiliser un type conditionnel pour faire ce travail à notre place :

type Brand<T, U> = T & { __brand: U }; type Flavor<T, U> = T & { __flavor?: U }; type Nominal<T, U> = T extends object ? Flavor<T, U> : Brand<T, U>;

Références

Vous souhaitez être accompagné pour lancer votre projet digital ?
Déposez votre projet dès maintenant
Les Normes RGAA et l’accessibilité numérique
En France, 90 % de la population, parmi lesquels se trouvent vos clients, se connectent à des sites web et des contenus en ...
Arnaud Albalat
Arnaud Albalat
CTO @ Galadrim
Architecture hexagonale : principes, bénéfices et conception
Le choix de l'architecture de votre application web ou mobile se fait en général en début de projet, lors de la phase ...
Arnaud Albalat
Arnaud Albalat
CTO @ Galadrim
Fonction fléchée vs fonction traditionnelle en JavaScript
Si en JavaScript vous vous êtes déjà demandé quand utiliser les fonctions fléchées et quand utiliser les fonctions traditionnelles, ...
Mayeul Le Monies de Sagazan
Mayeul Le Monies de Sagazan
Full-Stack Developer @ Galadrim