📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~13 min de lecture

4 champs optionnels = 16 états possibles, 4 valides : passe aux unions discriminées

Tu modélises l'état d'un écran. Tu écris le type le plus évident du monde :

type UserState = {
  loading?: boolean;
  user?: User;
  error?: string;
  fetchedAt?: Date;
};

Ça compile. Ça passe la review. Puis, trois semaines plus tard, ton composant contient state().user!.name, ton template a trois @if imbriqués dont personne ne sait dire dans quel ordre ils doivent être, et quand un bug remonte, la première question est toujours la même : "il est dans quel état, exactement ?".

Personne ne sait répondre. Pas parce que le code est mal écrit, mais parce que le type ne répond pas.

Les unions discriminées existent depuis TypeScript 2.0. Rien de neuf : ce qui est neuf, c'est le prix que tu paies quand tu ne les utilises pas dans une codebase à signals. Côté Angular, @if / @switch datent de la v17, mais @let (section sur les templates) est arrivé en 18.1 et n'est stable que depuis la v19. Tout est vérifié sur Angular 22 avec strictTemplates.


TL;DR

Modèle États autorisés par le type États valides ! / ?. au call site
Sac d'optionnels (4 champs) 16 sur le papier, 24 en vrai 4 partout
Union discriminée 4 4 zéro

Règle : si deux champs optionnels de ton type ne peuvent jamais être présents en même temps, tu as une union discriminée qui s'ignore.


Le sac d'optionnels, et pourquoi il ment

Combien d'états ce type autorise-t-il ? Quatre champs optionnels, donc seize combinaisons de présence/absence.

En comptant les valeurs plutôt que la seule présence, c'est pire : loading en a trois habitables (true, false, undefined) contre deux pour chacun des trois autres (une valeur, ou rien). Soit 3 x 2 x 2 x 2 = vingt-quatre.

Maintenant la vraie question : combien ton domaine en a-t-il ? Spontanément tu réponds trois. Chargement, succès, erreur. Garde ce chiffre en tête, on va y revenir : il est faux, et c'est le type qui t'a permis de ne jamais t'en rendre compte.

Voilà trois valeurs que TypeScript accepte sans broncher :

const a: UserState = {};
const b: UserState = { loading: true, error: 'timeout', user: someUser };
const c: UserState = { loading: false, error: 'timeout' };

a : rien en cours, rien chargé, rien en erreur. Ton composant affiche quoi ? Personne ne l'a décidé.

b : en chargement, en erreur, et avec une donnée. Les trois branches de ton rendu sont vraies simultanément. C'est la première qui gagne, donc c'est l'ordre de tes if qui tranche.

c : une erreur alors que le chargement est explicitement terminé, sans aucune donnée. Plausible, ou bug de transition ? Le type ne tranche pas, et ton code de rendu non plus.

Le coût n'est pas esthétique. Le type est la spécification de ton domaine, et c'est le seul document que le compilateur sait lire. Quand cette spécification autorise vingt-quatre états dont vingt n'ont aucun sens métier, TypeScript ne peut plus t'aider : il ignore la règle "loading exclut error", donc il te force à tout vérifier, partout, y compris les cas que tu sais impossibles.

Tu connais la suite. Comme vérifier partout est fatigant, quelqu'un finit par écrire !.


Le prix se paie au call site : ordre des if, ! partout, return poubelle

Le sac d'optionnels ne pourrit pas que la définition. Il pourrit surtout les endroits qui la consomment :

function render(state: UserState): string {
  if (state.loading) return 'Chargement...';
  if (state.error) return `Erreur : ${state.error}`;
  if (state.user) return state.user.name;
  return '';
}

Trois problèmes, et aucun n'est visible à l'œil nu :

  1. L'ordre des if est devenu la spécification. Inverse les deux premières lignes et tu changes le comportement de l'écran en cas de retry. Aucune vérification de type ne bronche, parce qu'aucun type n'exprime la priorité.
  2. Le dernier return est une poubelle. C'est là que tombent l'état initial légitime et les vingt états absurdes, mélangés. Tu ne peux pas distinguer "l'utilisateur n'a pas encore cliqué" de "on est dans un état qui n'aurait jamais dû exister". Les deux rendent la même chaîne vide.
  3. Le narrowing est local et fragile. state.user est bien rétréci à User à la troisième ligne, mais déplace cet accès dans une fonction appelée juste après et tu repars de User | undefined. Le ! arrive par là.

Même maladie côté signature de fonction :

function parseUser(raw: unknown): { ok?: boolean; user?: User; error?: string } {
  throw new Error('TODO');
}

Lis cette signature et réponds : peut-on avoir ok: true et user: undefined ? Est-ce que ok absent vaut false ou vaut "pas encore évalué" ? Faut-il tester ok ou tester user ?

Impossible à savoir sans ouvrir l'implémentation. C'est exactement ce qu'on appelle un type peu parlant : il décrit la forme en mémoire, pas le contrat.


Le discriminant : un champ littéral, présent partout

type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; user: User; fetchedAt: Date }
  | { status: 'failure'; message: string };

Trois conditions pour que TypeScript reconnaisse une union discriminée :

  1. le champ discriminant est présent dans tous les membres (status partout, jamais optionnel) ;
  2. son type est un littéral, pas string ;
  3. les littéraux sont deux à deux distincts.

Ce qui a disparu : tous les ?. Et c'est le point le plus important de l'article.

user?: User dit "tu peux demander, ça peut être vide". Un membre qui ne déclare pas user dit autre chose : la question n'a pas de sens ici. Demander user sur un état loading, ce n'est pas lire une valeur absente, c'est une erreur de raisonnement, et le compilateur la refuse à la compilation au lieu de te renvoyer undefined à l'exécution.

Même remarque pour fetchedAt : il n'existe que dans success, où il est garanti. Dans le sac d'optionnels, c'était un quatrième ? à vérifier partout.

Et voilà le chiffre promis. Vingt-quatre états deviennent quatre, pas trois. Le quatrième, c'est idle. Il existait depuis le début, tu ne l'avais simplement jamais nommé, parce que le sac d'optionnels te permettait de ne pas le faire : c'était le {} du départ, celui qui tombait dans le return '' avec les états absurdes. Modéliser en union t'oblige à le nommer, donc à décider ce qu'il affiche.

C'est le vrai gain, et il arrive avant la première ligne de code de rendu : le type te force à finir ta spécification.


Ce que le narrowing te rend

function render(state: UserState): string {
  switch (state.status) {
    case 'idle':
      return 'Clique pour charger';
    case 'loading':
      return 'Chargement...';
    case 'success':
      return `${state.user.name} (${state.fetchedAt.toISOString()})`;
    case 'failure':
      return `Erreur : ${state.message}`;
  }
}

Zéro !, zéro ?., zéro branche poubelle. Dans le case 'success', state.user est de type User, pas User | undefined : TypeScript a éliminé les trois autres membres de l'union.

Et surtout : l'ordre des case n'a plus aucune importance. Il ne peut plus y avoir deux branches vraies en même temps, donc la priorité n'est plus une règle implicite planquée dans l'ordre des lignes.

Le narrowing ne se limite pas au switch. Un ===, un ternaire, un if early-return, un .filter() typé avec un prédicat, tout fonctionne :

const failures = states.filter(
  (s): s is Extract<UserState, { status: 'failure' }> => s.status === 'failure',
);
// failures: { status: 'failure'; message: string }[]

Extract<UserState, { status: 'failure' }> évite de redéclarer le membre à la main. Si tu ajoutes un champ à l'état failure, ce code suit tout seul. Et si tu renommes le littéral, la comparaison ne pourrit pas en silence : TypeScript te sort un TS2367 parce que les deux types n'ont plus d'intersection.


Le check d'exhaustivité, là où ça devient rentable

Le vrai retour sur investissement n'est pas dans le code que tu écris aujourd'hui, mais dans celui que tu modifieras dans six mois.

function assertNever(value: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}

function label(state: UserState): string {
  switch (state.status) {
    case 'idle':
      return 'Prêt';
    case 'loading':
      return 'Chargement';
    case 'success':
      return state.user.name;
    case 'failure':
      return state.message;
    default:
      return assertNever(state);
  }
}

Dans le default, TypeScript a éliminé les quatre membres : il ne reste rien, donc state vaut never, donc l'appel compile.

Maintenant ajoute un cinquième état à l'union :

type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'refreshing'; user: User }
  | { status: 'success'; user: User; fetchedAt: Date }
  | { status: 'failure'; message: string };

Chaque switch qui ne traite pas refreshing casse immédiatement, avec le message exact :

Argument of type '{ status: "refreshing"; user: User; }' is not assignable to parameter of type 'never'.

Le compilateur vient de te donner la liste exhaustive des fichiers à modifier, et il te nomme l'état oublié. Compare avec le sac d'optionnels : tu ajoutes refreshing?: boolean, tout compile, et tu pars faire un grep en espérant que personne n'ait écrit !state.loading quelque part.

Une précision qui compte. Si ta fonction a un type de retour annoté qui n'inclut pas undefined, TypeScript signale déjà le cas manquant sans assertNever :

error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Mais ce diagnostic pointe la signature et ne te dit pas lequel des états manque. assertNever pointe le default et te nomme le coupable. Et il reste indispensable dès que la fonction retourne void, fait un effet de bord, ou déclare string | undefined, cas où TypeScript se tait complètement.


Côté Angular : le piège du narrowing en template

Le pattern se transpose directement dans un composant à signals. Avec une nuance qui coûte une heure à qui ne la connaît pas.

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [DatePipe, Spinner],
  template: `
    @let s = state();

    @switch (s.status) {
      @case ('loading') {
        <app-spinner />
      }
      @case ('success') {
        <p>{{ s.user.name }}</p>
        <small>{{ s.fetchedAt | date: 'short' }}</small>
      }
      @case ('failure') {
        <p class="error">{{ s.message }}</p>
      }
      @default {
        <button type="button" (click)="load()">Charger</button>
      }
    }
  `,
})
export class UserCard {
  private readonly api = inject(UserApi);

  protected readonly state = signal<UserState>({ status: 'idle' });

  protected load(): void {
    this.state.set({ status: 'loading' });
    // ...
  }
}

Le @let n'est pas cosmétique : il te faut une référence stable. Retire-le, écris la version directe :

@switch (state().status) {
  @case ('success') {
    <p>{{ state().user.name }}</p>
  }
}

et le compilateur te sort, avec strictTemplates activé :

error TS2339: Property 'user' does not exist on type 'UserState'.
  Property 'user' does not exist on type '{ status: "idle"; }'.

Raison : TypeScript ne rétrécit que des références stables (variable, propriété). state() est un appel de fonction, et rien ne garantit au compilateur que deux appels successifs renvoient la même valeur.

Ce comportement n'a d'ailleurs rien de spécifique à Angular, c'est du TypeScript pur. Ce qui est propre à Angular, c'est la fréquence : les templates te poussent à écrire state() en ligne partout. Et le remède, qui existe en deux formes, toutes deux vérifiées à l'ngc sur Angular 22 : @let s = state(); (qui rétrécit aussi bien en @switch qu'en @if (s.status === 'success')), et @if (state(); as s). Seule la forme sans alias échoue.

Attention quand même à @if (state(); as s) : ce n'est pas un substitut universel, puisqu'il saute silencieusement la vue si la valeur est falsy. Ici l'union n'est faite que d'objets, donc aucun risque. Sur d'autres modèles, @let est le choix sûr. Si tu veux creuser, les 5 patterns sont détaillés dans @let dans les templates Angular.

Anecdote qui confirme la direction : Angular a fait le même choix, et jusqu'au bout. resource() expose un status typé 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local', et surtout, depuis la v22, un snapshot() qui est une vraie union discriminée :

type ResourceSnapshot<T> =
  | { readonly status: 'idle'; readonly value: T }
  | { readonly status: 'loading' | 'reloading'; readonly value: T }
  | { readonly status: 'resolved' | 'local'; readonly value: T }
  | { readonly status: 'error'; readonly error: Error };

Regarde le dernier membre : en 'error', la propriété value n'existe pas. C'est exactement la démonstration de la section précédente, signée par l'équipe Angular. Les commodités isLoading et hasValue() existent toujours à côté, mais c'est snapshot() qui porte le contrat.


Les 4 pièges qui cassent le discriminant

Piège 1 : le discriminant inféré en string

const next = { status: 'success', user, fetchedAt: new Date() };
// inféré : { status: string; user: User; fetchedAt: Date }

this.state.set(next); // erreur d'assignation, ou pire : un cast pour la faire taire

TypeScript élargit le type d'un littéral de chaîne dès que la propriété qui le porte est mutable. status devient string, et string n'appartient à aucun membre de ton union.

Trois corrections :

const a = { status: 'success', user, fetchedAt: new Date() } satisfies UserState;
const b: UserState = { status: 'success', user, fetchedAt: new Date() };
const c = { status: 'success' as const, user, fetchedAt: new Date() };

Les trois marchent, mais satisfies est le seul à combiner les deux garanties. Il vérifie l'appartenance à l'union, ce que as const ne fait pas (une typo 'succes' passerait en silence). Et il conserve le type étroit quand la valeur quitte sa portée locale : champ de classe, propriété d'objet, valeur de retour.

C'est là que se joue la différence avec l'annotation : UserState. En local immédiat, elle suffit largement : TypeScript rétrécit b à partir de son initialiseur, et b.user est directement accessible. Mais range la même valeur dans un champ de classe et le narrowing est perdu :

class WithAnnotation {
  state: UserState = { status: 'success', user, fetchedAt: new Date() };
  name() { return this.state.user; } // TS2339: Property 'user' does not exist on type 'UserState'
}

class WithSatisfies {
  state = { status: 'success', user, fetchedAt: new Date() } satisfies UserState;
  name() { return this.state.user; } // OK
}

Piège 2 : le discriminant optionnel

type Bad = { kind?: 'a'; value: number } | { kind?: 'b'; label: string };

kind vaut 'a' | undefined d'un côté, 'b' | undefined de l'autre. Les case explicites rétrécissent toujours correctement, ce n'est pas là qu'est le problème : c'est qu'il reste un résidu undefined qu'aucun case ne couvre. Ton switch a un trou permanent et assertNever ne compile plus. Un discriminant n'est jamais optionnel.

Piège 3 : le spread qui reconstruit un sac

C'est le plus courant, et le plus sournois dans une codebase à signals :

this.state.update(s => ({ ...s, status: 'loading' }));

Le réflexe vient de Redux et des objets d'état plats. Ici, le spread traîne user et fetchedAt dans un état loading, ce qui te ramène exactement au problème de départ : un objet qui porte des champs sans rapport avec son état courant.

Et voilà pourquoi ça passe la review. Écris la même valeur en littéral frais, TypeScript la rejette :

error TS2353: Object literal may only specify known properties, and 'user' does not exist in type '{ status: "loading"; }'.

Le spread, lui, neutralise le contrôle de propriétés excédentaires. Le compilateur ne dit rien.

Avec une union discriminée, un état n'est pas un delta. Construis-le en entier :

this.state.set({ status: 'loading' });

Si un champ doit vraiment survivre à la transition (garder l'ancienne donnée affichée pendant un refresh, par exemple), c'est une décision de modélisation, pas un effet de bord du spread. Elle se déclare dans le type :

| { status: 'refreshing'; user: User }

Piège 4 : le discriminant qui n'existe pas dans le payload

Si ton union modélise une réponse d'API, le discriminant doit venir du serveur ({ "type": "weather", ... }), ou tu dois le poser toi-même. Et si tu le poses toi-même, fais-le une seule fois, au bord : dans le mapper HTTP, le resolver, le map() de ton service. Jamais dans trois composants qui devinent chacun à leur façon.

private toState(res: UserResponse): UserState {
  return res.data
    ? { status: 'success', user: res.data, fetchedAt: new Date() }
    : { status: 'failure', message: res.message ?? 'Erreur inconnue' };
}

Une fonction, un point d'entrée, et tout ce qui est en aval travaille sur une union propre. C'est aussi le seul endroit qui a le droit de manipuler la forme brute et floue de l'API.


Où l'appliquer en priorité

Le loading state est l'exemple d'école, mais il est loin d'être le seul :

Situation Union discriminée
Fonction faillible { ok: true; value: T } | { ok: false; error: E }
Événements / actions { type: 'ADD'; id: string } | { type: 'CLEAR' }
Formulaire création / édition { mode: 'create' } | { mode: 'edit'; entityId: string }
Payload polymorphe d'API { kind: 'weather'; temp: number } | { kind: 'stock'; ticker: string }
Variantes de composant { variant: 'link'; href: string } | { variant: 'button'; action: () => void }

Le premier cas est celui que tu utilises déjà sans le nommer : safeParse() de Zod retourne, en substance, { success: true; data: T } | { success: false; error: ZodError }. C'est la raison pour laquelle tester result.success te donne result.data sans !. Zod pousse même le raffinement plus loin en croisant des error?: never et data?: never sur les deux membres, ce qui rend le narrowing bidirectionnel : tester result.data marche aussi bien que tester result.success.

Le troisième mérite un mot. Le formulaire création/édition est le cas où le sac d'optionnels fait le plus de dégâts : entityId?: string force chaque méthode du composant à se demander si on est en édition, alors que mode le dit une fois pour toutes et que entityId devient garanti là où il compte.


Le before/after, en entier

// AVANT : 24 etats habitables, 4 voulus (dont un que personne n'a jamais nomme)
type UserState = {
  loading?: boolean;
  user?: User;
  error?: string;
  fetchedAt?: Date;
};

// APRES : 4 etats habitables, 4 voulus
type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; user: User; fetchedAt: Date }
  | { status: 'failure'; message: string };

Ce qui change n'est pas la longueur : elle est comparable. C'est que la deuxième version n'a plus aucun endroit où tu dois te demander ce qui est vrai en même temps que quoi. La réponse est dans le type, et elle est vérifiée à chaque build.


En résumé

Le critère de détection tient en une phrase : si deux champs optionnels de ton type ne peuvent jamais être présents ensemble, tu as une union discriminée qui s'ignore.

La checklist de conversion :

  1. Énumère les états réels de ton domaine. Compte bien : il y en a souvent un de plus que tu ne crois, celui que les ? t'ont permis de ne jamais nommer.
  2. Nomme le discriminant (status, type, kind, mode) et donne-lui un littéral distinct par état.
  3. Déplace chaque champ dans le ou les membres où il a un sens. Les ? doivent disparaître.
  4. Remplace la cascade de if par un switch sur le discriminant, avec assertNever dans le default.
  5. Construis les états en entier (set), jamais par patch partiel (update + spread).
  6. En template Angular, passe par une référence stable (@let, ou @if (...; as ...) si la valeur ne peut pas être falsy) avant de tester le discriminant.

Ce que tu y gagnes : les ! disparaissent parce qu'ils n'ont plus de raison d'être, la branche poubelle disparaît parce qu'il n'y a plus d'état non spécifié, et l'ajout d'un état devient une opération guidée par le compilateur au lieu d'une chasse au grep.

Le type redevient ce qu'il aurait toujours dû être : la description de ce qui peut arriver, pas la liste de ce qui traîne en mémoire.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.