Union discriminée dans TypeScript pour state et API response
1. Vue d’ensemble
Nombreuses sont les erreurs d’interface qui proviennent d’états qui ne devraient jamais se produire. Par exemple, isLoading est true mais error a aussi une valeur. Ou data est undefined alors que la requête a indiqué succès.
Cet article vous guide sur l’utilisation de la union discriminée de TypeScript pour modéliser les états de chargement de données et les réponses API. Après l’avoir lu, vous saurez comment :
- définir une union avec un champ discriminant,
- laisser le compilateur affiner les types automatiquement (narrowing),
- forcer la gestion de toutes les branches avec la vérification d’exhaustivité,
- valider les réponses au runtime avec Zod,
- écrire des tests pour les transitions d’état.
2. Le problème avec le « sac de champs optionnels »
La façon habituelle d’écrire est de regrouper tout dans une interface avec des champs optionnels :
interface OrderState {
isLoading: boolean;
error?: string;
order?: { id: string; total: number };
}
const state: OrderState = {
isLoading: true,
error: "Timeout",
order: { id: "A1", total: 120 },
};
Le code ci-dessus compile sans problème. Cependant, il décrit un état sans sens : à la fois chargement en cours, erreur, et données disponibles.
Avec ces trois champs, vous avez jusqu’à 2 × 2 × 2 = 8 combinaisons. En réalité, seules environ 4 combinaisons sont valides. Quand vous lisez l’état, vous devez aussi écrire state.order?.total ou state.order! partout. Quand il y a une erreur, le compilateur ne peut rien faire pour vous.
3. Qu’est-ce qu’une union discriminée
Une union discriminée est une union de plusieurs types d’objets. Chaque type a un champ commun avec un literal type différent. Ce champ s’appelle le discriminant, ou « tag » :
type Order = { id: string; total: number };
type OrderState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; order: Order }
| { status: "error"; message: string };
Ici, status est le discriminant. Chaque branche déclare seulement les champs qui ont du sens pour elle. La branche success a order, la branche error a message. L’état « chargement en cours mais aussi erreur » n’est plus possible :
const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.
3.1. Narrowing selon le discriminant
Quand vous vérifiez la valeur du discriminant, TypeScript affine automatiquement le type dans la branche correspondante :
function describe(state: OrderState): string {
if (state.status === "success") {
return `Commande ${state.order.id} : ${state.order.total}€`;
}
if (state.status === "error") {
return `Erreur : ${state.message}`;
}
return state.status === "loading" ? "Chargement..." : "Non commencé";
}
console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
Commande A1 : 120€
Erreur : Timeout
À l’intérieur de la branche if (state.status === "success"), le type de state est { status: "success"; order: Order }. Par conséquent, vous pouvez accéder directement à state.order.id, sans ?. ou !. Si vous tapez state.message dans cette branche, le compilateur signalera une erreur.
4. Exhaustiveness check : ne pas oublier un état
Une union n’est vraiment utile que si ajouter une branche fait que le compilateur indique tous les endroits où la branche n’est pas traitée. Le moyen courant est d’utiliser switch avec le type never :
function assertNever(value: never): never {
throw new Error(`État non traité : ${JSON.stringify(value)}`);
}
function render(state: OrderState): string {
switch (state.status) {
case "idle":
return "<button>Charger la commande</button>";
case "loading":
return "<spinner />";
case "success":
return `<p>Total : ${state.order.total}€</p>`;
case "error":
return `<p class="error">${state.message}</p>`;
default:
return assertNever(state);
}
}
Après avoir passé les quatre case, le type de state dans la branche default est never. L’appel assertNever(state) est donc valide.
Supposons que plus tard vous ajoutiez la branche { status: "cancelled" } à OrderState mais oubliiez de mettre à jour render :
error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.
L’erreur apparaît à la compilation, avant que l’utilisateur ne voie un écran blanc. Si des données étrangères passaient quand même au runtime, par exemple d’une API non fiable, assertNever lèverait une erreur claire au lieu de rester silencieux et retourner undefined.
Note : Si vous activez
"strict": trueet déclarez explicitement le type de retour (: string), oublier uncaseprovoquera aussi l’erreur « Function lacks ending return statement ». Néanmoins,assertNevervaut toujours le coup d’être utilisé. Il donne un message d’erreur plus clair et fonctionne même avec une fonction qui retournevoid.
5. Modéliser la réponse API
Une API retourne généralement deux formes : succès ou échec. Vous pouvez définir un type Result réutilisable :
type Result<T, E = ApiError> =
| { ok: true; data: T }
| { ok: false; error: E };
type ApiError =
| { kind: "network"; cause: string }
| { kind: "http"; status: number }
| { kind: "invalid"; issues: string[] };
Le discriminant n’a pas besoin d’être une chaîne. ok: true et ok: false sont deux literal types du booléen, donc le narrowing fonctionne toujours. ApiError est aussi une union discriminée imbriquée à l’intérieur, différenciée par kind.
5.1. Valider la réponse au runtime avec Zod
Les types TypeScript disparaissent après la compilation. Si vous écrivez (await res.json()) as Order, vous ne faites que « promettre » au compilateur, vous ne vérifiez rien du tout. Pour être sûr que les données du serveur ont la bonne forme, validez avec Zod :
npm install zod
Supposons que le serveur retourne un payload avec un discriminant propre type :
import { z } from "zod";
const OrderSchema = z.object({ id: z.string(), total: z.number() });
const OrderResponseSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("found"), order: OrderSchema }),
z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);
type OrderResponse = z.infer<typeof OrderResponseSchema>;
z.discriminatedUnion("type", [...]) crée un schéma basé sur le champ type. Zod lit d’abord la valeur de type puis valide selon la branche correcte, donc les messages d’erreur sont plus précis que z.union. z.infer génère le type TypeScript correspondant, vous n’avez pas besoin de l’écrire deux fois.
Vient ensuite la fonction qui appelle l’API, enveloppant toutes les erreurs dans Result :
async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
let res: Response;
try {
res = await fetch(`/api/orders/${id}`);
} catch (e) {
return { ok: false, error: { kind: "network", cause: String(e) } };
}
if (!res.ok) {
return { ok: false, error: { kind: "http", status: res.status } };
}
const parsed = OrderResponseSchema.safeParse(await res.json());
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
return { ok: false, error: { kind: "invalid", issues } };
}
return { ok: true, data: parsed.data };
}
Cette fonction ne lève jamais d’erreur. Chaque situation est exprimée dans le type de retour. safeParse lui-même retourne aussi une union discriminée avec un discriminant success. C’est exactement le pattern que vous apprenez.
Le côté qui appelle la fonction gère le résultat comme suit :
async function showOrder(id: string): Promise<string> {
const result = await fetchOrder(id);
if (!result.ok) {
switch (result.error.kind) {
case "network": return "Connexion perdue, réessayez plus tard.";
case "http": return `Le serveur a retourné ${result.error.status}.`;
case "invalid": return "Les données ne sont pas valides.";
default: return assertNever(result.error);
}
}
return result.data.type === "found"
? `Total : ${result.data.order.total}€`
: `Commande ${result.data.orderId} introuvable`;
}
Après la ligne if (!result.ok) { ... }, TypeScript sait que result est certainement la branche ok: true. Par conséquent, result.data est accessible directement.
6. Transitions d’état avec un reducer
Les unions discriminées fonctionnent très bien avec les reducers, comme useReducer de React ou un store maison. L’état et l’action sont tous deux des unions :
type OrderAction =
| { type: "FETCH" }
| { type: "RESOLVE"; order: Order }
| { type: "REJECT"; message: string }
| { type: "RESET" };
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case "FETCH":
return state.status === "loading" ? state : { status: "loading" };
case "RESOLVE":
return state.status === "loading"
? { status: "success", order: action.order }
: state;
case "REJECT":
return state.status === "loading"
? { status: "error", message: action.message }
: state;
case "RESET":
return { status: "idle" };
default:
return assertNever(action);
}
}
Ce reducer encode aussi les règles de transition d’état. RESOLVE n’a d’effet que quand on est en loading. Grâce à cela, une réponse qui arrive tard après que l’utilisateur ait déjà fait RESET sera ignorée. Vous n’avez pas besoin de drapeau supplémentaire.
7. Écrire des tests pour le comportement clé
L’article utilise Vitest. Sa syntaxe expect est similaire à celle d’AssertJ et Jest :
npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";
describe("orderReducer", () => {
const order = { id: "A1", total: 120 };
it("passe de loading à success avec RESOLVE", () => {
const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "success", order });
});
it("ignore RESOLVE qui arrive tard quand déjà idle", () => {
const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "idle" });
});
it("render affiche le message d'erreur", () => {
expect(render({ status: "error", message: "Timeout" }))
.toBe('<p class="error">Timeout</p>');
});
});
npx vitest run
✓ order.test.ts (3 tests)
✓ orderReducer > passe de loading à success avec RESOLVE
✓ orderReducer > ignore RESOLVE qui arrive tard quand déjà idle
✓ orderReducer > render affiche le message d'erreur
Test Files 1 passed (1)
Tests 3 passed (3)
Le deuxième test est le plus important. Il verrouille le comportement anti-race condition, le type d’erreur très facile à rencontrer avec le modèle « sac de champs optionnels ».
8. Erreurs courantes et quand ne pas utiliser
Le discriminant devient string. Quand vous créez un objet dans une variable intermédiaire, TypeScript peut déduire le type string au lieu d’un literal :
const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.
Vous avez trois façons de corriger : déclarer le type dès le départ (const s: OrderState = ...), utiliser as const, ou utiliser satisfies OrderState.
Destructurer trop tôt casse la liaison. Si vous écrivez let { status, ...rest } = state, TypeScript ne sait plus à quelle branche appartient rest quand vous vérifiez status. Le narrowing via destructuring ne fonctionne que quand la variable est déclarée avec const et vous ne la réassignez pas.
Utiliser l’opérateur in au lieu du discriminant. Vérifier "order" in state fait quand même narrowing. Cependant, c’est facile de casser quand deux branches ont le même nom de champ. Vérifiez toujours via le discriminant.
Le discriminant est optionnel ou peut être modifié. Ne déclarez pas status?: .... Ne faites pas non plus state.status = "error" sur un objet existant. Au lieu de cela, créez un nouvel objet pour chaque état. Vous pouvez marquer le champ comme readonly pour que le compilateur respecte cette règle.
Forcer le cast au lieu de valider. Écrire as OrderResponse pour des données du réseau supprime tous les bénéfices ci-dessus. Aux limites du système, validez toujours les données au runtime.
Note : Ne devriez pas utiliser d’union discriminée quand les champs sont vraiment indépendants. Par exemple, un formulaire avec
email?,phone?,note?peut être rempli selon n’importe quelle combinaison. Le champ optionnel est le bon modèle. Les forcer dans une union ne fait que faire exploser le nombre de branches sans gain de sécurité.
9. Conclusion
Les unions discriminées vous aident à transformer les états invalides en erreurs de compilation. Vous avez vu comment :
- définir une union avec un discriminant literal,
- laisser TypeScript affiner automatiquement selon le discriminant,
- utiliser
assertNeverpour forcer la gestion de toutes les branches, - modéliser les réponses API avec
Resultet valider les données avecz.discriminatedUnion, - appliquer le même pattern aux reducers et le vérifier avec des tests.
La règle courte est : si les champs n’ont du sens ensemble que pour certains cas, mettez-les dans la même branche de l’union. La prochaine fois que vous allez écrire isLoading à côté de error? et data?, essayez plutôt un champ status.