Le trimestre dernier, nous avons livré un bug qui a remboursé le mauvais client. Une fonction processRefund a reçu un identifiant utilisateur là où elle attendait un identifiant de commande. Les chaînes de caractères étaient identiques, les tests passaient, et TypeScript n’a soulevé aucune objection.

Les deux identifiants étaient typés comme string. TypeScript ne voit aucune différence entre eux. Si vous avez déjà passé un userId dans un paramètre qui attend un orderId et regardé le compilateur hausser les épaules, vous avez heurté le même mur.

Les alias de types ne corrigent pas ça. Pas plus que de nommer vos variables avec soin. Le compilateur fait exactement ce que vous lui avez dit de faire, et c’est là le problème.

Pourquoi type UserId = string est un mensonge

TypeScript utilise le typage structurel. Deux types sont compatibles si leurs structures correspondent. Quand vous écrivez type UserId = string, vous n’avez pas créé un nouveau type. Vous avez créé un alias. À la compilation, UserId et string sont identiques. Il en va de même pour UserId et OrderId.

type UserId = string;
type OrderId = string;

function fetchOrder(id: OrderId) {
  // ...
}

const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.

Le compilateur élimine les alias de types pendant la vérification des types. Le nom UserId est destiné aux humains. Le vérificateur de types l’ignore. C’est généralement une fonctionnalité. Cela permet d’échanger des implémentations sans cérémonie. Mais pour des identifiants qui ne devraient jamais être interchangeables, c’est un piège.

Les types brandés rendent des structures identiques incompatibles

La solution est un type brandé, parfois appelé type opaque ou newtype. Vous intersectez le type sous-jacent avec une marque unique qui n’existe qu’au niveau du type.

À l’exécution, la valeur n’est toujours qu’une chaîne de caractères. À la compilation, la marque la rend distincte de toute autre chaîne.

Voici le motif utilisant un symbole unique :

declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };

Le unique symbol garantit qu’aucun autre type ne peut accidentellement partager cette marque. La propriété readonly signifie que vous ne pouvez pas la muter. À l’exécution, la propriété symbole n’existe pas sur la chaîne réelle, il s’agit donc purement d’une construction à la compilation.

Pour créer une valeur, vous avez besoin d’une fonction constructeur qui caste la chaîne brute :

function UserId(value: string): UserId {
  return value as UserId;
}

function OrderId(value: string): OrderId {
  return value as OrderId;
}

Maintenant, le bug précédent est détecté avant même que vous n’exécutiez le code :

const uid = UserId("usr_0192");
fetchOrder(uid);
//     ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

Le message d’erreur est clair. Les types sont structurellement différents parce que leurs marques diffèrent. Le compilateur ne vous laissera pas les confondre.

Le code répétitif est ennuyeux. Voici un utilitaire.

Écrire une marque et un constructeur pour chaque type d’ID devient vite répétitif. Nous utilisons un petit utilitaire qui génère les deux :

interface Brand<T> {
  readonly __brand: T;
}

type Branded<T, B> = T & Brand<B>;

function makeBrand<T, B>(
  _brand: B
): (value: T) => Branded<T, B> {
  return (value) => value as Branded<T, B>;
}

Utilisation :

type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");

type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");

Cela maintient la déclaration à deux lignes par identifiant. Vous pouvez aussi utiliser l’approche par symbole unique dans un utilitaire générique si vous préférez une meilleure résistance aux collisions. Dans les deux cas, l’objectif est le même : rendre le coût d’ajout d’un nouveau type brandé suffisamment bas pour que vous le fassiez réellement.

Et les objets et les nombres ?

Les types brandés fonctionnent pour n’importe quel primitif, pas seulement les chaînes. Nous brandons les identifiants numériques de la même manière :

type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");

Vous pouvez aussi brander des objets. Si vous avez deux configurations qui partagent la même forme mais ne devraient jamais être échangées, brandez-les :

type ApiConfig = Branded<{
  endpoint: string;
  timeout: number;
}, "ApiConfig">;

Soyez prudent avec le branding d’objets, cependant. L’objet à l’exécution n’aura pas la propriété de marque, donc JSON.stringify et les opérations de spread se comportent normalement. C’est généralement ce que vous voulez. Mais si vous effectuez des vérifications d’égalité profonde ou passez des valeurs dans des bibliothèques qui inspectent les types à l’exécution, la marque ne sera pas là pour vous aider. C’est strictement une garde à la compilation.

Les compromis sont réels, mais minimes

Les types brandés ajoutent de la friction. Chaque ID nécessite maintenant un appel au constructeur. Vous ne pouvez pas passer une chaîne brute en ligne dans une fonction qui attend un type brandé. C’est le but, mais cela signifie plus de code.

La sérialisation est un autre piège. Quand vous faites JSON.stringify sur une chaîne brandée, vous récupérez la chaîne brute. Quand vous l’analysez plus tard, vous avez perdu la marque. Vous devez réappliquer le constructeur aux limites du système. Nous le faisons généralement dans les analyseurs de réponses API et les mappeurs de lignes de base de données.

const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));

Les types brandés n’aident pas non plus avec la validation à l’exécution. Si une chaîne est malformée, la marque ne la détectera pas. Vous avez toujours besoin de zod, valibot, ou d’une validation manuelle aux limites de votre système. La marque garantit la distinction des types, pas la correction des données.

Quand cela en vaut la peine, et quand c’est du bruit

Nous brandons les identifiants qui traversent les limites de modules : identifiants utilisateur, identifiants d’organisation, identifiants de trace, identifiants de span. Ce sont les valeurs qui parcourent le plus de code et qui ont le plus de chances d’être passées dans le mauvais ordre.

Nous ne brandons pas les compteurs de boucle internes, les variables temporaires locales, ou quoi que ce soit avec une portée inférieure à une fonction. Le surcoût n’en vaut pas la peine pour des valeurs qui ne quittent jamais leur lieu de naissance.

Si votre base de code a une signature de fonction avec trois paramètres chaîne consécutifs, c’est un signal fort. Les types brandés transforment ce site d’appel d’un jeu de devinettes en quelque chose que le compilateur vérifie pour vous.

Un point de départ pratique

Vous n’avez pas besoin de brander chaque chaîne de votre base de code demain. Choisissez les identifiants qui ont causé de vrais bugs. Ajoutez des marques pour ceux-là. Regardez le compilateur détecter une confusion lors de votre prochaine refactorisation. Ce moment unique, où une erreur de build empêche un bug en production, est celui où les appels de constructeur supplémentaires commencent à paraître bon marché.

Commencez par une limite de module. Ajoutez une marque UserId. Ajoutez une marque OrderId. Mettez à jour votre couche base de données pour appliquer les constructeurs quand les lignes arrivent. Voyez ce que ça donne. Si cela détecte un mauvais argument lors d’une revue de code, cela s’est amorti.