Deux types qui partagent exactement la même forme sont-ils le même type ? Pour TypeScript, la réponse est oui, et c’est précisément ce qui pose problème dès que l’on cherche à modéliser des concepts distincts mais structurellement identiques. Un UserId et un OrderId sont tous deux des chaînes de caractères ; rien n’empêche, par défaut, de passer l’un là où l’on attend l’autre. Les Branded Types sont la technique qui rétablit cette distinction au niveau du compilateur, sans changer la valeur manipulée à l’exécution.
Pour comprendre d’où vient cette limite et comment la lever, il faut d’abord revenir sur la façon dont TypeScript décide que deux types sont équivalents.
Type-system structurel contre type-system nominal
TypeScript fait partie des langages qui reposent sur un type-system structurel, contrairement à Java, par exemple, qui utilise un type-system nominal. La différence est fondamentale et conditionne tout le reste.
Le typage structurel de TypeScript
Dans un type-system structurel, l’équivalence de deux types A et B est déterminée structurellement : A et B sont considérés comme égaux dès lors qu’ils partagent la même structure de type. Le nom qu’on leur a donné n’a aucune importance.
Ainsi, pour TypeScript, deux types comme type Employee = { name: string } et type Visitor = { name: string } seront considérés comme parfaitement équivalents : un Employee pourra être utilisé partout où un Visitor est attendu, et inversement.
Le typage nominal des autres langages
Un type-system nominal, lui, fonde l’équivalence de type sur le nommage des types eux-mêmes. C’est le modèle retenu par de nombreux langages que vous connaissez : C++, Rust, Java, C#, etc.
En Java, par exemple, deux classes comme class Cat { public void walk() {} } et class Dog { public void walk() {} } partagent pourtant la même structure (une méthode walk) mais ne seront jamais considérées comme équivalentes. Leur identité tient à leur nom, pas à leur forme.
La limite du typage purement structurel
La difficulté avec un typage uniquement structurel est la suivante : il devient impossible de représenter de manière unique deux types qui ont la même structure (ou le même type primitif) mais qui portent des significations différentes et ne doivent surtout pas être interchangeables.
C’est un cas extrêmement courant. Un identifiant d’utilisateur et un identifiant de commande sont tous deux des string. Une distance en mètres et une distance en pieds sont toutes deux des number. Un email et un mot de passe sont tous deux des string. Le compilateur, lui, ne voit que la structure : pour lui, ce sont des types parfaitement substituables. Toute la sémantique métier qui les distingue se perd, et avec elle la possibilité de détecter à la compilation qu’on a interverti deux valeurs.
La bonne nouvelle, c’est qu’avec TypeScript on peut reproduire l’un des traits clés d’un type-system nominal, et même aller un cran plus loin sur les types primitifs, grâce aux Branded Types.
Qu’est-ce qu’un Branded Type ?
Un Branded Type est un type auquel on associe un identifiant unique au type-level, appelé brand (la « marque »), dont le seul rôle est de distinguer deux types qui auraient autrement la même structure.
L’intérêt est double.
D’abord, le brand garantit une unicité au type-level alors même que le type sous-jacent reste structurellement équivalent. Notre type Employee ne pourra plus être utilisé à la place d’un Visitor, bien que les deux partagent exactement les mêmes propriétés. On retrouve ici le comportement d’un système nominal, sur demande, là où on en a besoin.
Ensuite, et c’est le gros avantage propre à cette technique, on peut appliquer un brand à des types primitifs comme number ou string. Un type UserEmail ne pourra plus, par défaut, être utilisé à la place d’un type UserId, bien que les deux soient au fond des string. C’est exactement la garantie qui manque au typage structurel nu.
Il faut toutefois en connaître la contrepartie : par construction, les Branded Types n’offrent que des garanties au type-level. Ils peuvent donc être contournés par un cast de type (as). Mais cette limite est facile à lever : en passant systématiquement par une fonction de construction, on peut adosser une validation à la création du brand et garantir ainsi la safety jusqu’au runtime.
Les Branded Types « à la main » en TypeScript
La construction la plus directe repose sur une intersection avec un objet portant une propriété __brand typée comme un unique symbol. C’est ce symbole, unique par définition, qui rend chaque brand incompatible avec tous les autres.
/** Antoine COULON, introduction aux Branded Types avec TypeScript */
// Construction des Branded Types manuellement avec un unique symbol pour
// garantir l'unicité au type-level. Fonctionne avec les types primitifs !
type UserEmail = string & { readonly __brand: unique symbol };
type UserId = string & { readonly __brand: unique symbol };
// Fonctionne également avec les objets
type User = {
email: UserEmail;
id: UserId;
} & { readonly __brand: unique symbol };
// Ensuite, on peut utiliser ces types de manière tout à fait classique, mais
// avec plus de garanties
const userEmail: UserEmail = "some@email.com";
// ^^^^^^^^^^ Type 'string' is not assignable to type 'UserEmail'.
// Construction du Branded Type
function makeUserId(id: string): UserId {
// Possibilité de valider la donnée à la construction
// avant de retourner le Branded Type UserId
return id as UserId;
}
// Utilisation du Branded Type
function createUser(id: UserId) {
//
}
// Bien que "123" soit une string, le compilateur fait bien la différence avec un 'UserId'
createUser("123");
// ^^^ Argument of type 'string' is not assignable to parameter of type 'UserId'.
const userId = makeUserId("123");
createUser(userId);
// ^^^^^^ OK
Plusieurs choses méritent d’être soulignées dans cet exemple. L’affectation directe const userEmail: UserEmail = "some@email.com" échoue : une string brute n’est pas assignable au type brandé, ce qui force à passer par une voie contrôlée. La fonction makeUserId joue ce rôle de point de passage : c’est l’endroit idéal pour valider la donnée (vérifier un format, une longueur, une appartenance) avant de réaliser le cast as UserId qui appose le brand. Enfin, l’appel createUser("123") est rejeté par le compilateur alors que "123" est une chaîne parfaitement valide : c’est exactement la garantie qu’on cherchait, l’impossibilité de confondre une string quelconque avec un identifiant d’utilisateur.
Les Branded Types avec Effect
Implémenter ces brands à la main reste un peu verbeux, surtout dès qu’on veut y adosser une validation runtime systématique. La bibliothèque Effect fournit un module Brand dédié, qui formalise les deux usages : avec ou sans validation à l’exécution.
/** Antoine COULON, introduction aux Branded Types avec TypeScript + Effect */
import { Brand } from "effect";
type Int = number & Brand.Brand<"Int">;
// "refined" permet de créer un Branded Type avec une validation runtime
const Int = Brand.refined<Int>(
(n) => Number.isInteger(n), // Validation runtime
(n) => Brand.error(`Expected ${n} to be an integer`) // Erreur s'il y a mismatch
);
// Création d'un Branded Type "Int" dont la valeur est bien un integer
const X: Int = Int(3);
// Tentative de création à partir d'un float, ce qui va throw une erreur
const Y: Int = Int(3.14);
// On peut aussi créer des Branded Types sans validation runtime
type UserIdentifier = number & Brand.Brand<"UserIdentifier">;
const UserIdentifier = Brand.nominal<UserIdentifier>();
Deux constructeurs cohabitent ici. Brand.refined associe au type une validation à l’exécution : le constructeur Int n’accepte une valeur que si elle satisfait le prédicat Number.isInteger, et lève une erreur explicite dans le cas contraire. C’est ce qui referme la faille évoquée plus haut : un Int n’est plus seulement un number étiqueté au type-level, c’est un number dont on a vérifié à la construction qu’il était bien entier. À l’inverse, Brand.nominal crée un brand purement nominal, sans coût runtime : on ne cherche alors qu’à distinguer deux types au niveau du compilateur, comme UserIdentifier qui ne pourra plus être confondu avec un number ordinaire.
On notera aussi l’usage de Brand.Brand<"Int"> : Effect s’appuie sur un littéral de chaîne comme marque, plutôt que sur un unique symbol. La mécanique diffère, l’objectif reste le même : donner une identité unique à un type au niveau du système de types.
Conclusion
Le typage structurel de TypeScript est une force : il rend le langage souple et limite la cérémonie. Mais cette souplesse a un revers : l’incapacité à distinguer deux concepts qui partagent la même forme. Les Branded Types comblent précisément ce manque en réintroduisant, à la demande, l’unicité d’un type-system nominal, jusque sur les types primitifs.
La règle à retenir est simple : un brand seul ne protège qu’à la compilation, et reste contournable par un cast. Sa vraie valeur se révèle quand on l’adosse à une fonction de construction qui valide la donnée, manuellement ou via Brand.refined d’Effect. On obtient alors une garantie qui court du type-level jusqu’au runtime : non seulement le compilateur empêche de confondre un UserId avec un UserEmail, mais on sait aussi que toute valeur portant le brand a bien franchi le contrôle qu’on lui a imposé.