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;
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;
const commentId: CommentId = value;
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!' });
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!' });
const comment: PostComment = { author: 'matthieu', content: 'Hello world!' };
createPost(comment);
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;
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