Vol. 3 · № 03 · Un atelier TypeScriptA TypeScript workshop

Branded / opaque types, ou un string qui a fait ses preuves Branded / opaque types, or a string that earned its name

Le structurel rend un Email validé et un string brut interchangeables : sendTo(string) accepte tout. Le brand encode l'invariant validé dans le type — un champ fantôme qui rend Email distinct de string, qu'on n'obtient qu'en passant par un smart constructor. Coût nul à l'exécution (le brand s'efface), à la seule condition de confiner le as. À ne pas brander partout. Structural typing makes a validated Email and a raw string interchangeable: sendTo(string) accepts anything. The brand encodes the validated invariant into the type — a phantom field making Email distinct from string, obtained only through a smart constructor. Zero runtime cost (the brand erases), on the sole condition you confine the as. Don't brand everything.

AudienceAudience
Dev qui modélise en unions discriminées et veut pousser un invariant validé dans une valeur primitive Dev modeling with discriminated unions who wants to push a validated invariant into a primitive value
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ Branded typeNominalTradeoff
SommaireContents ·
№ 01CadrageFraming3 min

Le structurel rend un Email validé et un string brut interchangeables.Structural typing makes a validated Email and a raw string interchangeable.

C'est le revers du Vol 1 : la compatibilité se décide sur la forme. Un email validé a la forme d'un string — donc il EST un string, et n'importe quel string passe là où on attend un email. Résultat : on revalide partout, ou pire, on oublie. La question du volume, poussée à son sommet : cet invariant que je revérifie sans cesse, puis-je le rendre impossible à ne pas avoir prouvé ?It's the flip side of Vol 1: compatibility is decided on shape. A validated email has the shape of a string — so it IS a string, and any string passes where an email is expected. The result: you re-validate everywhere, or worse, you forget. The volume's question, pushed to its summit: this invariant I keep rechecking, can I make it impossible to hold without having proven?

L'alias — Email n'est qu'un string déguiséThe alias — Email is just a string in disguise
type Email = string;   // ✗ juste un alias : Email EST string, structurellement

function sendTo(to: Email) { /* ... */ }

sendTo("pas-un-email");      // compile — aucun email validé n'est exigé
sendTo(formInput.value);     // compile — string brut du dehors, jamais vérifié
Qui peut entrer dans sendTo() ?Who may enter sendTo()?
Alias stringString alias
n'importe quel stringany string at all
validé ou non — la porte est grande ouvertevalidated or not — the door is wide open
Type brandéBranded type
seulement un Email prouvéonly a proven Email
passé par le smart constructor, une seule portethrough the smart constructor, a single door

Avec un simple alias, sendTo accepte tout string : la validation est une promesse orale. Le brand referme la porte — on ne tient un Email qu'en l'ayant prouvé.With a plain alias, sendTo accepts any string: validation is a spoken promise. The brand shuts the door — you only hold an Email by having proven it.

Le type brandé — un string qui a fait ses preuvesThe branded type — a string that earned its name
type Email = string & { readonly __brand: "Email" };

function parseEmail(raw: string): Email | null {
  return /^[^@]+@[^@]+$/.test(raw) ? (raw as Email) : null;
}

function sendTo(to: Email) { /* ... */ }

sendTo("pas-un-email");      // ✗ string n'est PAS assignable à Email
const e = parseEmail(input);
if (e) sendTo(e);            // ✓ seul un Email prouvé franchit la porte
Le réflexe du numéroThe issue's reflex

« Cet invariant que je revérifie partout — puis-je le rendre impossible à ne pas avoir prouvé ? » Si une valeur n'est valide qu'après un contrôle, encode ce contrôle dans son type : un brand qu'on n'obtient qu'en passant par le validateur."This invariant I recheck everywhere — can I make it impossible to hold without having proven?" If a value is only valid after a check, encode that check into its type: a brand you obtain only by going through the validator.

Le sommet du Vol 3.The summit of Vol 3.

Le 3.1 rendait une mauvaise combinaison de formes irreprésentable ; le 3.2, un cas oublié impossible. Le 3.3 s'attaque à la dernière maille : la valeur de la bonne forme mais du mauvais contenu — un string qui ressemble à un email sans en être un. On pousse l'invariant jusque dans une valeur primitive.3.1 made a bad combination of shapes unrepresentable; 3.2, a forgotten case impossible. 3.3 tackles the last link: the value of the right shape but the wrong content — a string that looks like an email without being one. We push the invariant down into a primitive value.

№ 02La surpriseThe surprise4 min

Le brand est un fantôme : il n'existe qu'à la compilation. À l'exécution, Email est un string nu.The brand is a phantom: it exists only at compile time. At runtime, Email is a bare string.

La propriété __brand n'est jamais posée sur la valeur. parseEmail ne fait que mentir au compilateur via un as — sans rien ajouter au string. C'est l'effacement du Vol 2 retourné en outil : le brand vit dans le système de types et s'évapore au runtime. On obtient donc le nominal à la compilation, le structurel à l'exécution. Et la seule chose qui protège l'invariant, c'est que ce as soit confiné à un seul endroit : le validateur.The __brand property is never set on the value. parseEmail merely lies to the compiler via an as — adding nothing to the string. It's Vol 2's erasure turned into a tool: the brand lives in the type system and evaporates at runtime. So you get nominal at compile time, structural at runtime. And the only thing protecting the invariant is that this as is confined to a single place: the validator.

Le compilateur traite Email comme un type à partThe compiler treats Email as its own type
type Email = string & { readonly __brand: "Email" };

const raw: string = "a@b.co";
const mail: Email = raw;   // ✗ Type 'string' is not assignable to type 'Email'
//    ^ le compilateur traite Email comme DISTINCT de string : nominal simulé

Le champ fantôme __brand rend Email plus précis que string : un string n'a pas ce champ, donc il n'est pas assignable. C'est du typage nominal simulé — exactement ce que le structurel (Vol 1) ne donne pas, réintroduit à la carte.The phantom field __brand makes Email more precise than string: a string lacks that field, so it isn't assignable. It's simulated nominal typing — exactly what structural typing (Vol 1) withholds, reintroduced à la carte.

Un brand ne change pas la valeur — il change ce que le compilateur autorise. C'est l'effacement du Vol 2 retourné en garde : zéro coût au runtime, toute la preuve à la compilation.A brand doesn't change the value — it changes what the compiler allows. It's Vol 2's erasure turned into a guard: zero runtime cost, all the proof at compile time.
№ 03La mécaniqueThe mechanics5 min

Un type de base, une étiquette unique, et une seule porte d'entrée.A base type, a unique tag, and one way in.

Trois pièces. Le type de base (string, number) porte la donnée réelle. Le brand — une propriété fantôme avec une étiquette littérale unique — le rend distinct au compilateur. Le smart constructor est la seule fonction qui produit la valeur brandée : il valide, puis caste. Tant que le as ne vit que là, tenir la valeur prouve qu'elle est passée par le contrôle. Deux brands d'étiquettes différentes ne sont jamais interchangeables, même sur le même type de base.Three pieces. The base type (string, number) carries the real data. The brand — a phantom property with a unique literal tag — makes it distinct to the compiler. The smart constructor is the only function that produces the branded value: it validates, then casts. As long as the as lives only there, holding the value proves it went through the check. Two brands with different tags are never interchangeable, even on the same base type.

Le helper générique et ses brandsThe generic helper and its brands
// Le helper générique : un type de base + une étiquette unique.
type Brand<T, B extends string> = T & { readonly __brand: B };

type Email       = Brand<string, "Email">;
type UserId      = Brand<string, "UserId">;
type PositiveInt = Brand<number, "PositiveInt">;
Le smart constructor — où vit le seul as légitimeThe smart constructor — where the only legitimate as lives
// Le smart constructor : la SEULE porte qui produit un brand.
function toPositiveInt(n: number): PositiveInt {
  if (!Number.isInteger(n) || n <= 0) throw new Error("entier positif requis");
  return n as PositiveInt;   // le seul 'as' autorisé : ici, on a VRAIMENT vérifié
}

const id: UserId = "u_42" as Email;   // ✗ Email n'est pas UserId : brands distincts
Les trois pièces d'un brandThe three pieces of a brand
Ce qu'il fautWhat it takes
BaseBasele type réel : string, number…the real type: string, number…
ÉtiquetteTagun littéral unique par branda unique literal per brand
ConstructeurConstructorvalide, puis caste — une seule foisvalidates, then casts — just once
Ce que ça garantitWhat it guarantees
ProuvéProventenir la valeur = avoir validéholding the value = having validated
DisjointDisjointUserId ≠ Email, même base stringUserId ≠ Email, same string base
GratuitFreezéro coût runtime : le brand s'effacezero runtime cost: the brand erases
Les voisins, seulement nommés.The neighbors, only named.

On parle aussi de type opaque. Pour une étiquette infalsifiable, on remplace le string littéral par un unique symbol (personne ne peut le réécrire ailleurs). Et certains formats se décrivent directement au niveau type via les template literal types (`${string}@${string}`) — utile, mais ça contraint la forme, pas la validation réelle. Le brand simule le typage nominal que des langages comme Java ont nativement.It's also called an opaque type. For an unforgeable tag, swap the literal string for a unique symbol (no one can rewrite it elsewhere). And some formats describe themselves directly at the type level via template literal types (`${string}@${string}`) — handy, but it constrains shape, not real validation. The brand simulates the nominal typing that languages like Java have natively.

№ 04Le piègeThe trap4 min

Brander tout : une cérémonie qui coûte plus cher que le bug qu'elle prévient.Branding everything: a ceremony costing more than the bug it prevents.

Le brand a un prix : chaque conversion passe par un constructeur ou un as, et chaque calcul reperd le brand qu'il faut reposer. Sur une valeur qui traverse une frontière dangereuse — un email, un identifiant, un montant — ce prix est dérisoire face au bug évité. Sur un index de boucle qui ne quitte jamais la fonction, c'est de la cérémonie pure : du bruit qui masque l'intention, pour une confusion qui n'arriverait jamais. Brander, c'est un choix par invariant, pas un réflexe global.The brand has a price: every conversion goes through a constructor or an as, and every computation loses the brand you must reapply. On a value crossing a dangerous boundary — an email, an id, an amount — that price is trivial against the bug avoided. On a loop index that never leaves the function, it's pure ceremony: noise that hides intent, for a mix-up that would never happen. Branding is a choice per invariant, not a blanket reflex.

Le brand sur une valeur qui ne traverse rienA brand on a value that crosses nothing
type Index   = Brand<number, "Index">;
type Count   = Brand<number, "Count">;
type Celsius = Brand<number, "Celsius">;

function sum(xs: number[]): Count {
  let total = 0 as Count;                 // cérémonie…
  for (let i = 0 as Index; i < xs.length; i = (i + 1) as Index) {  // …à chaque ligne
    total = (total + xs[i]) as Count;     // …pour une valeur qui ne quitte jamais sum()
  }
  return total;
}

Chaque opération arithmétique « casse » le brand (un Count + number redevient number), qu'il faut reposer par un as. Le code se couvre de conversions sans qu'aucun bug réel soit en jeu : ces nombres ne quittent jamais sum(). C'est le contre-exemple obligatoire — brander a coûté plus que le bug qu'il prévient.Every arithmetic op "breaks" the brand (a Count + number becomes number again), to be reapplied with an as. The code drowns in conversions while no real bug is at stake: these numbers never leave sum(). This is the mandatory counter-example — branding cost more than the bug it prevents.

Le brand n'est honnête que si le as est confiné.The brand is only honest if the as is confined.

Tout le pouvoir tient à une discipline : le as Brand ne vit que dans le smart constructor. Si on écrit x as Email n'importe où, on contourne la validation — et c'est le type guard menteur du 2.3 qui renaît, un as qui affirme une preuve qu'on n'a pas faite. Le brand ne supprime pas le as ; il le confine à un seul endroit qu'on relit. C'est exactement la tension du Vol 4 : un as est-il une preuve qu'on peut justifier, ou un mensonge commode ?All the power rests on one discipline: the as Brand lives only in the smart constructor. Write x as Email anywhere and you bypass validation — and the lying type guard of 2.3 is reborn, an as asserting a proof you never made. The brand doesn't remove the as; it confines it to a single place you can review. That's exactly Vol 4's tension: is an as a proof you can justify, or a convenient lie?

Brander une valeur qui ne traverse aucune frontière, c'est payer une assurance contre un accident qui ne peut pas arriver. La preuve a un prix — ne l'achète que là où le bug fait mal.Branding a value that crosses no boundary is paying insurance against an accident that cannot happen. Proof has a price — buy it only where the bug hurts.
№ 05Bilan & éditoWrap-up & editorial2 min

Un string qui a fait ses preuves — et qu'un string nu ne peut plus usurper.A string that earned its name — one a bare string can no longer usurp.

Le brand referme le Vol 3 : on encode un invariant validé dans le type lui-même, et seul un passage par le smart constructor produit la valeur. Le compilateur refuse alors un string brut là où on attend un Email. C'est le nominal réintroduit à la carte sur le structurel du Vol 1 — pour zéro coût à l'exécution, et à la seule condition de confiner le as.The brand closes Vol 3: you encode a validated invariant in the type itself, and only a pass through the smart constructor produces the value. The compiler then refuses a raw string where an Email is expected. It's nominal typing reintroduced à la carte over Vol 1's structural typing — for zero runtime cost, and only if you confine the as.

Tu veux…You want to…Le gesteThe moveCe que ça coûteWhat it costs
Exiger une valeur validéeDemand a validated valueUn type brandé + un smart constructor unique.A branded type + a single smart constructor.Cérémonie : parser explicitement, gérer l'échec.Ceremony: parse explicitly, handle the failure.
Distinguer deux types de même formeTell apart two same-shaped typesDeux brands d'étiquettes distinctes (UserId ≠ OrderId).Two brands with distinct tags (UserId ≠ OrderId).Un constructeur par brand à maintenir.One constructor per brand to maintain.
Un coût nul à l'exécutionZero runtime costLe brand est un fantôme : il s'efface (Vol 2).The brand is a phantom: it erases (Vol 2).Rien ne le protège hors du compilateur — confine le as.Nothing protects it outside the compiler — confine the as.
Ne PAS l'employerNOT use itUne valeur interne qui ne traverse aucune frontière dangereuse.An internal value crossing no dangerous boundary.La cérémonie coûte plus que le bug évité.The ceremony costs more than the bug avoided.
Un invariant qu'on revérifie partout est un invariant qu'on peut oublier. Le pousser dans le type, c'est ne pouvoir tenir la valeur qu'en l'ayant prouvée une fois.An invariant rechecked everywhere is one you can forget. Pushing it into the type means you can only hold the value by having proven it once.
Mot de l'éditeurFrom the editor

« Cette valeur a-t-elle fait ses preuves ? »"Has this value earned its name?"

Le Vol 1 nous a appris la liberté du structurel : tout ce qui a la bonne forme passe. Le Vol 3 en révèle le revers — un email validé a la forme d'un string, donc rien ne le distingue d'un string brut, et l'invariant « ceci a été vérifié » se perd. Le brand répare cela à la carte : il rend au compilateur le seul morceau de typage nominal qui lui manquait, une identité qu'on gagne en la prouvant. La validation cesse d'être une promesse qu'on se répète à chaque fonction ; elle devient un fait inscrit dans le type, qu'on ne peut pas ne pas avoir établi. C'est l'aboutissement du volume : on ne vérifie plus l'état illégal — formes (3.1), cas (3.2), valeurs (3.3) — on le rend irreprésentable.Vol 1 taught us the freedom of structural typing: anything of the right shape passes. Vol 3 reveals its flip side — a validated email has the shape of a string, so nothing tells it from a raw one, and the invariant 'this was checked' is lost. The brand fixes that à la carte: it hands the compiler the one piece of nominal typing it lacked, an identity earned by proving it. Validation stops being a promise you repeat at every function; it becomes a fact written into the type, one you cannot have failed to establish. It's the volume's culmination: you no longer check the illegal state — shapes (3.1), cases (3.2), values (3.3) — you make it unrepresentable.

Mais tout ce pouvoir repose sur un seul as, confiné au constructeur — et le as, c'est précisément l'échappatoire que le prochain volume dissèque. as ne convertit rien et ne vérifie rien : il dit au compilateur « tais-toi, je sais mieux ». Bien employé, comme ici, c'est une preuve qu'on peut justifier ; mal employé, c'est un mensonge qui rétablit le bug à l'exécution. Le Vol 4 — any, unknown et l'échappatoire — sépare les deux, et démonte le faux confort de faire taire le compilateur.But all that power rests on a single as, confined to the constructor — and as is precisely the escape hatch the next volume dissects. as converts nothing and checks nothing: it tells the compiler 'be quiet, I know better'. Used well, as here, it's a proof you can justify; used badly, it's a lie that restores the runtime bug. Vol 4 — any, unknown and the escape hatch — separates the two, and dismantles the false comfort of silencing the compiler.