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

Discriminated unions, ou l'une de ces formes, exclusivement Discriminated unions, or one of these shapes, exclusively

Le sac de champs optionnels laisse construire « en cours, réussi ET en erreur » — 8 combinaisons, 5 absurdes, à exclure à la main. L'union discriminée ne décrit que les formes réelles : un discriminant littéral les sépare, l'état illégal ne se construit plus, et le narrowing du Vol 2 s'applique gratuitement. À une condition : que le tag soit littéral, jamais string. The bag of optional fields lets you build 'loading, succeeded AND errored' — 8 combinations, 5 absurd, to exclude by hand. The discriminated union describes only the real shapes: a literal discriminant separates them, the illegal state can't be built, and Vol 2's narrowing applies for free. On one condition: the tag must be literal, never string.

AudienceAudience
Dev qui maîtrise le narrowing et veut concevoir des types où l'illégal ne se construit pas Dev fluent in narrowing who wants to design types where the illegal can't be built
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ Union discriminéeDiscriminantTradeoff
SommaireContents ·
№ 01CadrageFraming3 min

Le sac de champs optionnels laisse construire des états qui ne devraient pas exister.The bag of optional fields lets you build states that shouldn't exist.

Un seul objet, un booléen et deux champs optionnels : et voilà l'état « en chargement, réussi, et en erreur » à la fois — parfaitement constructible, parfaitement absurde. On le « gère » alors à coups de if éparpillés, en priant pour avoir couvert toutes les combinaisons. La discriminated union pose la question à l'envers : et si cet état illégal ne se construisait même plus ?A single object, one boolean and two optional fields: and there it is, the 'loading, succeeded, and errored' state all at once — perfectly buildable, perfectly absurd. So you 'handle' it with scattered ifs, praying you covered every combination. The discriminated union flips the question: what if that illegal state could no longer be built at all?

Le sac de booléens — l'état absurde est constructibleThe bag of booleans — the absurd state is buildable
type RequestState = {
  loading: boolean;
  data?: User;
  error?: string;
};

// rien n'interdit l'état absurde :
const s: RequestState = { loading: true, data: user, error: "boom" };
// en cours ? réussi ? échoué ? les trois à la fois — et ça compile.
Combien d'états le type autorise-t-il ?How many states does the type allow?
Sac de champsBag of fields
2 × 2 × 2 = 8
dont 5 illégaux à gérer à la mainof which 5 are illegal, handled by hand
Union discriminéeDiscriminated union
3
exactement les états réels — pas un de plusexactly the real states — not one more

Le sac multiplie les combinaisons ; cinq sur huit n'ont aucun sens, et c'est à toi de les refuser à l'exécution. L'union ne décrit que les trois formes réelles — le reste devient impossible à écrire.The bag multiplies combinations; five out of eight make no sense, and it's on you to reject them at runtime. The union describes only the three real shapes — the rest becomes impossible to write.

L'union discriminée — l'état illégal est irreprésentableThe discriminated union — the illegal state is unrepresentable
type RequestState =
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

// l'état absurde ne se construit même plus :
const s: RequestState = { status: "loading", data: user };
// ✗ 'data' n'existe pas sur la branche "loading" — refusé à la compilation
Le réflexe du numéroThe issue's reflex

« Cet état que je vérifie partout — puis-je le rendre impossible à construire ? » Si deux champs ne devraient jamais coexister, ne les mets pas dans le même objet : mets-les dans des branches exclusives, séparées par un discriminant."This state I check everywhere — can I make it impossible to build?" If two fields should never coexist, don't put them in the same object: put them in exclusive branches, split by a discriminant.

Pourquoi ce numéro ouvre le Vol 3.Why this issue opens Vol 3.

Le Vol 2 apprenait à reconstruire une preuve à l'exécution, après coup. Le Vol 3 prend le problème en amont : concevoir des types où l'état invalide ne se construit pas. L'union discriminée en est la brique de base — et le narrowing du Vol 2 devient sa clé de lecture.Vol 2 taught how to rebuild a proof at runtime, after the fact. Vol 3 takes the problem upstream: designing types where the invalid state can't be built. The discriminated union is its basic brick — and Vol 2's narrowing becomes the key to reading it.

№ 02La surpriseThe surprise4 min

On ne peut pas lire .data sur l'union. Il faut d'abord prouver la branche.You can't read .data on the union. You must first prove the branch.

Le réflexe venu du sac de champs : accéder à s.data directement. Le compilateur refuse — data n'est garanti que sur une seule branche, pas sur l'union. Et c'est exactement la protection qu'on voulait : impossible de toucher un champ avant d'avoir établi qu'on est dans la forme qui le possède. Le discriminant est la clé qui ouvre la branche.The reflex inherited from the bag of fields: reach for s.data directly. The compiler refuses — data is guaranteed only on a single branch, not on the union. And that's exactly the protection we wanted: you can't touch a field before establishing you're in the shape that owns it. The discriminant is the key that opens the branch.

Toucher un champ que l'union ne garantit pasTouching a field the union doesn't guarantee
function render(s: RequestState) {
  return s.data;
  //       ^ ✗ 'data' n'existe pas sur le type RequestState
  //         (absent de la branche "loading" et "error")
}

L'erreur n'est pas une gêne, c'est le théorème : sur RequestState, le compilateur ne sait qu'une chose sûre — qu'il y a un champ status. Tout le reste dépend de laquelle des trois formes on tient. Il refuse de te laisser supposer la réponse.The error isn't a nuisance, it's the theorem: on RequestState, the compiler knows only one sure thing — there's a status field. Everything else depends on which of the three shapes you hold. It refuses to let you assume the answer.

Le discriminant est la réponse honnête au Vol 2 : on ne peut pas interroger un type à l'exécution, alors on range l'identité de la forme dans une valeur qu'on peut lire.The discriminant is Vol 2's honest answer: you can't query a type at runtime, so you store the shape's identity in a value you can read.
№ 03La mécaniqueThe mechanics5 min

Un champ commun, littéral, distinct par membre — et le switch qui s'ouvre.One field, common, literal, distinct per member — and the switch that unfolds.

Une union devient « discriminée » dès qu'un champ commun la rend lisible : le discriminant (ou tag). TypeScript repère que chaque membre fixe ce champ à une valeur littérale différente, et s'en sert pour rétrécir. C'est tout le mécanisme — un switch sur le tag, et chaque case devient une branche typée à une seule forme.A union becomes 'discriminated' the moment a common field makes it readable: the discriminant (or tag). TypeScript notices each member pins that field to a different literal value, and uses it to narrow. That's the whole mechanism — a switch on the tag, and each case becomes a branch typed to a single shape.

L'anatomie — où est le discriminantThe anatomy — where the discriminant lives
type Shape =
  | { kind: "circle"; r: number }
  | { kind: "rect"; w: number; h: number };
//   ^^^^ le discriminant : un champ COMMUN, de type LITTÉRAL, DISTINCT par membre
Le switch sur le tag — une branche, une formeThe switch on the tag — one branch, one shape
function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;   // s : { kind: "circle"; r: number }
    case "rect":   return s.w * s.h;             // s : { kind: "rect"; w, h }
  }
}
Les trois conditions d'un discriminantThe three conditions of a discriminant
Ce qu'il fautWhat it takes
CommunCommonle même nom de champ sur tous les membresthe same field name on every member
LittéralLiteral"circle", "rect" — pas string"circle", "rect" — not string
DistinctDistinctune valeur différente par membrea different value per member
Ce que ça débloqueWhat it unlocks
======rétrécit dans la branche du ifnarrows inside the if branch
switchswitchun case par membre, chacun typéone case per member, each typed
GratuitFreeaucun guard à écrire, aucun castno guard to write, no cast
Les voisins, seulement nommés.The neighbors, only named.

« Tagged union » est le même mécanisme, sous son autre nom. Le tag n'est pas forcément un string : un littéral numérique ou booléen discrimine aussi ({ ok: true; ... } | { ok: false; ... }). Et quand les membres n'ont pas de champ commun, l'opérateur in ("r" in s) sert de discriminant de secours — moins lisible, mais valide."Tagged union" is the same mechanism, under its other name. The tag needn't be a string: a numeric or boolean literal discriminates too ({ ok: true; ... } | { ok: false; ... }). And when members share no common field, the in operator ("r" in s) serves as a fallback discriminant — less readable, but valid.

№ 04Le piègeThe trap4 min

Un discriminant typé string ne discrimine rien.A discriminant typed string discriminates nothing.

Le piège silencieux : tout a l'air d'une union discriminée — un champ status, un if dessus — mais le narrowing ne se produit pas, et data reste inaccessible. La cause : le discriminant est typé string, pas un littéral. Comparer un string à "ok" n'élimine aucune branche, parce que les deux membres acceptent n'importe quel string. Le tag ne sépare les formes que s'il est littéral.The silent trap: it all looks like a discriminated union — a status field, an if on it — but narrowing never happens, and data stays out of reach. The cause: the discriminant is typed string, not a literal. Comparing a string to "ok" eliminates no branch, because both members accept any string. The tag only separates the shapes if it's literal.

Deux branches que rien ne distingueTwo branches nothing tells apart
type Ok  = { status: string; data: User };     // ✗ status: string
type Err = { status: string; error: string };  // ✗ status: string
type State = Ok | Err;

function f(s: State) {
  if (s.status === "ok") {
    s.data;   // ✗ 'data' n'existe pas : 'ok' n'est qu'UN string parmi l'infini
  }           //   le test n'élimine NI Ok NI Err — aucun narrowing
}

Comme Ok et Err acceptent tous deux status: string, le test === "ok" n'en exclut aucun : après le if, s est encore Ok | Err. Même cause fréquente : un objet déclaré sans as const, dont TypeScript élargit le tag de "ok" à string avant même qu'il rejoigne l'union.Since Ok and Err both accept status: string, the === "ok" test excludes neither: after the if, s is still Ok | Err. A common cause: an object declared without as const, whose tag TypeScript widens from "ok" to string before it even joins the union.

Le contre-exemple : ne pas tout discriminer.The counter-example: don't discriminate everything.

Une union discriminée modélise « l'une de ces formes, exclusivement ». Si les états ne sont pas exclusifs — trois cases à cocher indépendantes dans un formulaire — les forcer en union, c'est énumérer huit membres pour trois booléens : pire que le sac. Le réflexe a un revers : discriminer ce qui est réellement mutuellement exclusif, et laisser les flags indépendants vivre côte à côte.A discriminated union models "one of these shapes, exclusively". If the states are not exclusive — three independent checkboxes in a form — forcing them into a union means enumerating eight members for three booleans: worse than the bag. The reflex has a flip side: discriminate what is genuinely mutually exclusive, and let independent flags live side by side.

Le discriminant n'est pas le nom du champ, c'est le caractère littéral de sa valeur. Élargi en string, il ne sépare plus rien.The discriminant isn't the field's name, it's the literal nature of its value. Widened to string, it separates nothing anymore.
№ 05Bilan & éditoWrap-up & editorial2 min

L'une de ces formes — et pas un mélange.One of these shapes — not a blend.

L'union discriminée ouvre le Vol 3 : au lieu de vérifier à l'exécution qu'une combinaison est valide, on conçoit un type où la combinaison invalide ne se construit pas. Un discriminant littéral sépare les formes ; le narrowing du Vol 2 fait le reste, gratuitement. Reste une faille — rien ne t'oblige encore à traiter tous les cas.The discriminated union opens Vol 3: instead of checking at runtime that a combination is valid, you design a type where the invalid combination can't be built. A literal discriminant separates the shapes; Vol 2's narrowing does the rest, for free. One gap remains — nothing yet forces you to handle every case.

Tu veux…You want to…Le gesteThe moveCe que ça coûteWhat it costs
Interdire les états absurdesForbid absurd statesUne union de formes, chacune avec son discriminant littéral.A union of shapes, each with its literal discriminant.Un champ tag à poser sur chaque membre — un peu de cérémonie.A tag field to add on each member — a touch of ceremony.
Lire un champ en sûretéRead a field safelyNarrowe d'abord sur le discriminant (if / switch).Narrow on the discriminant first (if / switch).Accès direct refusé : il faut prouver la branche avant le champ.Direct access refused: prove the branch before the field.
Que le tag discrimine vraimentA tag that truly discriminatesUn type littéral, jamais string ; as const si besoin.A literal type, never string; as const if needed.Un tag élargi narrowe en silence vers rien — le bug le plus discret.A widened tag silently narrows to nothing — the quietest bug.
Ne PAS l'employerNOT use itQuand les états ne sont pas exclusifs : des flags indépendants restent des flags.When states aren't exclusive: independent flags stay flags.Une union de 8 membres pour 3 booléens — pire que le sac d'origine.An 8-member union for 3 booleans — worse than the original bag.
Vérifier qu'un état est valide, c'est admettre qu'il peut être invalide. Le rendre irreprésentable, c'est ne plus avoir à le vérifier.Checking a state is valid admits it can be invalid. Making it unrepresentable means never having to check.
Mot de l'éditeurFrom the editor

« Et si l'état illégal ne se construisait plus ? »"What if the illegal state could no longer be built?"

Le sac de champs optionnels est le réflexe par défaut, et le plus coûteux : il accepte toutes les combinaisons, dont la plupart n'ont aucun sens, et reporte sur le runtime — sur des if éparpillés, sur toi — la charge de les exclure une à une. L'union discriminée renverse l'effort : on décrit les seules formes réelles, on leur colle un tag littéral, et le compilateur refuse tout le reste à l'écriture. Le narrowing du Vol 2, qu'on a appris à reconstruire péniblement, devient ici un cadeau du modèle — un simple === sur le tag, et la bonne forme s'ouvre.The bag of optional fields is the default reflex, and the costliest: it accepts every combination, most of them meaningless, and offloads onto the runtime — onto scattered ifs, onto you — the burden of excluding them one by one. The discriminated union flips the effort: you describe only the real shapes, stamp each with a literal tag, and the compiler refuses all the rest at write time. Vol 2's narrowing, which we learned to rebuild painstakingly, becomes a gift from the model here — a plain === on the tag, and the right shape opens up.

Mais il reste une porte ouverte. Rien ne t'oblige, aujourd'hui, à traiter les trois branches : oublie un case dans le switch, ajoute un quatrième membre six mois plus tard, et le code compile en silence sur un cas non géré. C'est précisément ce que ferme le prochain numéro — never & l'exhaustivité : comment forcer le compilateur à casser la compilation dès qu'une variante est oubliée. L'union pose les formes ; never garde la porte.But one door stays open. Nothing forces you, today, to handle all three branches: forget a case in the switch, add a fourth member six months later, and the code compiles silently on an unhandled case. That's exactly what the next issue closes — never & exhaustiveness: how to force the compiler to break the build the moment a variant is forgotten. The union lays down the shapes; never guards the door.