Menu

Branded types en TypeScript : typage nominal avec exemples

Un branded type est un type primitif portant une étiquette invisible, comme string & { readonly __brand: "UserId" }, pour qu'un UserId ne puisse pas être passé là où un OrderId est attendu. Découvrez le fonctionnement des brands, les fonctions constructrices qui valident, un helper générique Brand, les brands par unique symbol et les nombres brandés.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

TypeScript compare les types par leur forme, donc deux alias de string sont interchangeables. Un branded type ajoute une étiquette qui n'existe que dans le système de types, string & { readonly __brand: "UserId" }, ce qui rend un UserId incompatible avec une chaîne simple et avec tout autre brand :

À l'exécution, userId n'est que la chaîne "u_42". Le brand est une étiquette de compilation, et son seul rôle est d'empêcher de mélanger des identifiants, des unités et des chaînes validées.

Le problème : les alias ne sont que des noms

Un alias de type ne crée pas de nouveau type. Il donne un second nom à un type existant, et le compilateur traite les deux noms comme une seule et même chose :

C'est le typage structurel : TypeScript vérifie que la forme convient, et string convient à string. Pour les objets, les formes diffèrent en général ; pour les identifiants, les emails, les devises et les unités, jamais. Les brands corrigent ce cas précis.

Comment fonctionne le brand

string & { readonly __brand: "UserId" } est une intersection : une valeur doit être une chaîne et posséder aussi une propriété __brand de type "UserId". Aucune vraie chaîne n'a cette propriété, donc aucune chaîne simple ne lui est affectable :

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

Le sens qui compte fonctionne toujours : un UserId est une chaîne, vous pouvez donc le passer à tout ce qui accepte une chaîne, appeler .startsWith() dessus ou le mettre dans un template. Le brand ne bloque que l'entrée.

Des fonctions constructrices qui valident

L'assertion as UserId est la seule porte d'entrée, et une assertion ne vérifie rien. Placez-la dans une fonction qui valide l'entrée : chaque valeur brandée du programme est alors assurée d'avoir passé cette vérification.

sendWelcome ne vérifie plus jamais son entrée, car le type de son paramètre indique que la vérification a déjà eu lieu. C'est l'idée « parse, don't validate » : vérifier à la frontière, puis porter la preuve dans le type. Un type guard sert aussi de constructeur si vous préférez un booléen à une exception : function isEmail(s: string): s is Email.

Un helper générique Brand

Écrire l'intersection à la main pour chaque type devient répétitif. Un petit générique le fait une fois pour toutes :

Brander un nombre fonctionne comme brander une chaîne. Notez que amount / 100 est un simple number : l'arithmétique sur un nombre brandé donne un résultat sans brand, comme expliqué plus bas.

Brands avec unique symbol

Un nom de propriété chaîne comme __brand ressemble à une vraie propriété : userId.__brand passe la vérification comme "UserId" mais vaut undefined à l'exécution, et deux bibliothèques pourraient choisir le même nom. Une clé unique symbol évite les deux problèmes :

declare const brand: unique symbol déclare un symbole qui n'existe que pour le vérificateur de types ; le mot-clé declare signifie qu'aucun JavaScript n'est émis pour lui. Comme le symbole n'est pas exporté de son module, le code des autres fichiers ne peut même pas nommer la propriété du brand : hors de ce module, les seules façons d'obtenir un Meters sont les fonctions que vous exportez ou une assertion as Meters.

Les brands ne coûtent rien à l'exécution

Le code compilé ne contient aucune trace du brand. Voici les lignes émises pour l'exemple Meters, sous l'en-tête de module ajouté par le compilateur : le declare, les deux alias de type et chaque as ont disparu, et l'appel dont l'erreur est supprimée sur la dernière ligne s'exécute quand même.

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

Une valeur brandée est le primitif ordinaire : typeof donne "string" ou "number", JSON.stringify l'écrit comme d'habitude, et les comparaisons fonctionnent comme avant. La contrepartie est que rien n'est vérifié à l'exécution, sauf si votre fonction constructrice le vérifie. Des données issues de JSON, d'une base de données ou d'une URL arrivent comme string, et ne deviennent un UserId qu'en passant par cette fonction.

L'arithmétique et les méthodes perdent le brand

Les opérations sur une valeur brandée renvoient le type de base, car le brand ne fait pas partie de ce que produisent + ou .slice() :

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

C'est généralement ce que l'on veut : additionner deux montants en centimes donne des centimes, mais multiplier des centimes par des centimes non, et vous seul savez quelles opérations conservent le sens. Écrivez de petits helpers comme addCents(a: Cents, b: Cents): Cents pour les opérations dont votre code a besoin.

Quand utiliser les branded types

Utilisez les brands là où confondre deux valeurs du même type primitif est un vrai risque et où le compilateur ne peut pas aider autrement :

SituationExemples de brands
Identifiants de tables différentesUserId, OrderId, ProductId
Chaînes validéesEmail, Url, NonEmptyString, Slug
Unités et devisesMeters, Feet, Cents, Usd, Eur
Texte assaini ou échappéSafeHtml, SqlIdentifier
Nombres dans un intervallePercentage, PositiveInt

Évitez-les pour des valeurs qu'on ne confond jamais, et pour des types objets dont la forme diffère déjà. Les bibliothèques de validation peuvent produire des branded types à partir d'un schéma : avec Zod, z.string().brand<"UserId">() donne un schéma dont parse renvoie un UserId brandé, ce qui évite d'écrire les fonctions constructrices à la main.

Questions fréquentes

Que sont les branded types en TypeScript ?

Un motif qui rend incompatibles deux types ayant la même représentation à l'exécution. On croise le type de base avec une étiquette qu'aucune valeur ordinaire ne possède : type UserId = string & { readonly __brand: "UserId" }. Une chaîne simple, ou un OrderId avec une autre étiquette, est alors refusée là où un UserId est attendu.

TypeScript a-t-il des types nominaux ?

Non. Le système de types de TypeScript est structurel : deux types de même forme sont interchangeables, quels que soient leurs noms. Les déclarations de classe avec des membres private ou #private se comportent de façon nominale, et les branded types sont la méthode courante pour obtenir le même effet sur des primitifs comme les chaînes et les nombres.

Les branded types ont-ils un coût à l'exécution ?

Non. Le brand n'existe que dans le type. À l'exécution, la valeur reste une chaîne ou un nombre ordinaire, sans propriété supplémentaire, et le JavaScript compilé est identique à celui sans brand. Le seul code exécuté est la validation que vous choisissez de mettre dans la fonction qui crée les valeurs brandées.

Comment créer une valeur d'un branded type ?

Avec une assertion de type, idéalement dans une petite fonction qui vérifie d'abord l'entrée : function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Garder le as à cet unique endroit garantit que chaque Email du programme a passé la vérification.

Quelle est la différence entre un alias de type et un branded type ?

type UserId = string n'est qu'un nouveau nom : n'importe quelle chaîne est acceptée là où un UserId est attendu. type UserId = string & { readonly __brand: "UserId" } est un nouveau type incompatible : une chaîne simple doit d'abord passer par une fonction constructrice ou une assertion.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER