Pourquoi les exceptions deviennent vite fragiles
En JavaScript, lancer une exception avec throw est simple. Trop simple, parfois. Une fonction peut sembler retourner une valeur parfaitement typée, alors qu’elle peut interrompre le programme à tout moment avec une erreur non visible dans sa signature.
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error("Utilisateur introuvable");
}
return response.json();
}
Le type Promise<User> raconte une histoire incomplète. Il ne dit rien sur les erreurs réseau, les statuts HTTP invalides, les données mal formées ou les annulations de requête. Dans une petite fonction isolée, ce n’est pas dramatique. Dans une application React, Vue ou Node.js, cela produit souvent des try/catch dispersés, des messages d’erreur incohérents et des états d’interface difficiles à raisonner.
Le Result pattern consiste à représenter explicitement une opération qui peut réussir ou échouer. Au lieu de cacher l’échec dans une exception, on le place dans le type de retour.
Cette approche complète très bien le typage des données API en TypeScript, les machines à états en TypeScript et les stratégies de cache frontend stale-while-revalidate, car elle force le code à traiter les cas limites au bon endroit.
Définir un type Result minimal
Un Result est généralement une union discriminée composée de deux formes : succès ou erreur.
type Ok<T> = {
ok: true;
value: T;
};
type Err<E> = {
ok: false;
error: E;
};
type Result<T, E> = Ok<T> | Err<E>;
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E>(error: E): Err<E> {
return { ok: false, error };
}
Grâce au champ ok, TypeScript sait automatiquement affiner le type.
const result = await getUser("42");
if (result.ok) {
console.log(result.value.name);
} else {
console.error(result.error.message);
}
Ici, aucun try/catch n’est nécessaire au point d’appel. Le type oblige à gérer les deux branches. C’est précisément l’intérêt : rendre les chemins d’exécution visibles.
Modéliser des erreurs métier, pas seulement des Error
Une erreur JavaScript classique contient souvent un message, parfois une stack trace, mais rarement une information métier fiable. Dans une interface, on veut plutôt savoir si l’utilisateur est hors ligne, si une ressource est introuvable, si les données sont invalides ou si une session a expiré.
type ApiError =
| { type: "network"; message: string }
| { type: "not-found"; resource: string }
| { type: "unauthorized" }
| { type: "invalid-data"; issues: string[] }
| { type: "unknown"; message: string };
Ce type est plus exploitable qu’un simple Error. Il permet d’adapter l’interface, la journalisation et les actions de récupération.
function getErrorMessage(error: ApiError): string {
switch (error.type) {
case "network":
return "Impossible de contacter le serveur.";
case "not-found":
return `${error.resource} est introuvable.`;
case "unauthorized":
return "Vous devez vous reconnecter.";
case "invalid-data":
return "La réponse reçue est invalide.";
case "unknown":
return error.message;
}
}
Avec cette forme, les erreurs ne sont plus de simples chaînes de caractères. Elles deviennent une partie de l’architecture.
Appliquer Result à une requête fetch
Prenons une fonction de récupération utilisateur. Elle peut échouer pour plusieurs raisons : problème réseau, statut HTTP, JSON invalide ou données qui ne respectent pas le contrat attendu.
type User = {
id: string;
name: string;
email: string;
};
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const user = value as Record<string, unknown>;
return (
typeof user.id === "string" &&
typeof user.name === "string" &&
typeof user.email === "string"
);
}
async function getUser(id: string): Promise<Result<User, ApiError>> {
let response: Response;
try {
response = await fetch(`/api/users/${id}`);
} catch {
return err({
type: "network",
message: "La requête réseau a échoué"
});
}
if (response.status === 401) {
return err({ type: "unauthorized" });
}
if (response.status === 404) {
return err({ type: "not-found", resource: "Utilisateur" });
}
if (!response.ok) {
return err({
type: "unknown",
message: `Erreur HTTP ${response.status}`
});
}
const data: unknown = await response.json();
if (!isUser(data)) {
return err({
type: "invalid-data",
issues: ["La réponse ne correspond pas au format User"]
});
}
return ok(data);
}
Cette fonction est plus longue qu’une version avec throw, mais elle est aussi beaucoup plus honnête. Sa signature indique clairement : soit on obtient un User, soit on obtient une ApiError.
Dans un vrai projet, vous pouvez remplacer le type guard manuel par une validation Zod, Valibot ou ArkType. Le principe reste identique : les données externes entrent dans l’application sous forme de unknown, puis sont validées avant d’être utilisées.
Intégrer Result dans un composant React
Dans React, le Result pattern évite de mélanger les erreurs techniques, les états de chargement et les données valides.
import { useEffect, useState } from "react";
type AsyncState<T, E> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: E };
function UserProfile({ id }: { id: string }) {
const [state, setState] = useState<AsyncState<User, ApiError>>({
status: "idle"
});
useEffect(() => {
let ignore = false;
async function loadUser() {
setState({ status: "loading" });
const result = await getUser(id);
if (ignore) return;
if (result.ok) {
setState({ status: "success", data: result.value });
} else {
setState({ status: "error", error: result.error });
}
}
loadUser();
return () => {
ignore = true;
};
}, [id]);
if (state.status === "idle" || state.status === "loading") {
return <p>Chargement...</p>;
}
if (state.status === "error") {
return <p>{getErrorMessage(state.error)}</p>;
}
return (
<article>
<h2>{state.data.name}</h2>
<p>{state.data.email}</p>
</article>
);
}
Pour une gestion plus complète des effets asynchrones, notamment l’annulation de requêtes lors d’une recherche dynamique ou d’un changement de page, vous pouvez combiner cette approche avec AbortController en JavaScript.
Composer plusieurs opérations
Le Result pattern devient particulièrement intéressant quand plusieurs étapes peuvent échouer. Par exemple : lire un profil, charger ses permissions, puis construire un modèle d’affichage.
async function getUserDashboard(
userId: string
): Promise<Result<UserDashboard, ApiError>> {
const userResult = await getUser(userId);
if (!userResult.ok) {
return userResult;
}
const permissionsResult = await getPermissions(userResult.value.id);
if (!permissionsResult.ok) {
return permissionsResult;
}
return ok({
user: userResult.value,
permissions: permissionsResult.value,
canEdit: permissionsResult.value.includes("edit")
});
}
Ce code peut sembler verbeux, mais il évite un piège fréquent : perdre le contexte précis de l’erreur. Chaque étape retourne une erreur typée, que l’appelant peut afficher, journaliser ou transformer.
Vous pouvez aussi écrire des helpers pour rendre la composition plus fluide.
function map<T, E, U>(
result: Result<T, E>,
transform: (value: T) => U
): Result<U, E> {
if (!result.ok) return result;
return ok(transform(result.value));
}
Ce type d’utilitaire est utile, mais il faut rester pragmatique. Le but n’est pas de transformer toute votre base de code en bibliothèque fonctionnelle abstraite. Le but est de clarifier les zones où les échecs font partie du fonctionnement normal.
Quand utiliser Result, et quand garder throw
Le Result pattern n’est pas une règle absolue. Il est excellent pour les erreurs attendues : validation, formulaire invalide, API indisponible, ressource absente, permission refusée, données externes incohérentes.
En revanche, throw reste pertinent pour les erreurs de programmation : invariant impossible, configuration manquante au démarrage, branche censée être inaccessible, bug interne. Ces erreurs ne sont pas des cas métier à afficher proprement, mais des signaux qu’il faut corriger le code.
Une règle simple fonctionne bien : si l’appelant peut raisonnablement réagir à l’erreur, retournez un Result. Si l’erreur indique que le programme est dans un état invalide, une exception peut rester appropriée.
Bonnes pratiques d’architecture
Évitez de créer un type d’erreur global qui contient tout. Préférez des erreurs adaptées au domaine : ApiError, AuthError, ValidationError, StorageError. Cela rend les signatures plus précises.
Ne retournez pas Result<T, string> par défaut. Une chaîne est facile à écrire, mais difficile à exploiter. Une union discriminée donne plus d’options : traduction, affichage contextualisé, métriques, redirection ou action de récupération.
Placez la conversion des exceptions au bord du système. Par exemple, une fonction qui appelle fetch, localStorage, IndexedDB ou une API externe peut capturer les exceptions et les transformer en erreurs typées. Le reste de l’application manipule ensuite des valeurs prévisibles.
Enfin, testez les branches d’erreur. Un des grands bénéfices du Result pattern est de rendre ces branches faciles à construire dans les tests.
const failure: Result<User, ApiError> = err({
type: "unauthorized"
});
if (!failure.ok) {
expect(getErrorMessage(failure.error)).toBe("Vous devez vous reconnecter.");
}
Conclusion
Le Result pattern apporte une discipline simple : une opération qui peut échouer doit le dire dans son type. En TypeScript, cette discipline améliore la lisibilité, la robustesse et la testabilité du code.
Il ne remplace pas toutes les exceptions, mais il réduit fortement les erreurs invisibles dans les zones les plus sensibles : appels API, validation, persistance locale, logique métier et états d’interface.
Pour une application web sérieuse, c’est un excellent compromis entre rigueur et pragmatisme : moins de magie, plus de contrats explicites, et des interfaces qui savent vraiment quoi faire quand quelque chose se passe mal.