Pendant longtemps, je suis tombé dans le piège du concept d’Errors as Values : je voulais représenter toutes les erreurs possibles. Quand j’ai commencé à utiliser Effect en TypeScript il y a environ trois ans, j’ai été totalement conquis par cette idée. Mais à force de tout vouloir modéliser, j’ai fini par alourdir mes types avec des erreurs qui n’avaient rien à y faire.
La leçon que j’en ai tirée tient en une phrase : toutes les erreurs n’ont pas vocation à être reflétées au type-level.
Errors as Values : modéliser l’erreur comme une donnée
L’idée derrière les Errors as Values est simple et puissante : on modélise et on manipule les erreurs comme des valeurs ordinaires, plutôt que comme des exceptions qui empruntent un chemin d’exécution parallèle et invisible. L’erreur cesse d’être un événement qu’on « attrape » quelque part plus haut dans la pile ; elle devient une valeur que le type system connaît, qu’on est forcé de considérer, et que le compilateur nous aide à traiter.
Mon erreur a été de pousser le curseur trop loin : je faisais remonter tout au type-level, y compris les erreurs purement techniques. Or modéliser une erreur comme une valeur typée n’est pertinent que pour une partie d’entre elles.
Deux catégories fondamentalement différentes
Pour sortir de l’ornière, il faut distinguer deux familles d’erreurs qui n’ont ni la même nature, ni le même destinataire.
Recoverable errors : typées et explicites
Ce sont les domain errors, les erreurs métier. Un consommateur peut, et doit, les gérer. Elles font partie du contrat exposé au type-level : un utilisateur introuvable, une validation qui échoue, une ressource interdite selon les droits. Dans tous ces cas, le code appelant a une alternative fonctionnelle ou métier à proposer : afficher un message, proposer une autre action, retomber sur un comportement par défaut.
Ces erreurs ont leur place dans la signature des fonctions. C’est même tout leur intérêt : elles rendent le contrat explicite et obligent l’appelant à les traiter.
import { Effect, Data } from "effect";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform";
export class UserNotVerified extends Data.TaggedError("UserNotVerified")<{
userId: string;
}> {}
export const getUser = (
userId: string,
): Effect.Effect<User, UserNotVerified> => {
return Effect.fail(new UserNotVerified({ userId }));
};
export const someRouter = <R, E>(router: HttpRouter.HttpRouter<R, E>) =>
router.pipe(
HttpRouter.get(
"/users/:userId",
pipe(
Effect.gen(function* () {
const userId = yield* HttpRouter.getParam("userId");
const user = yield* getUser(userId);
return yield* HttpServerResponse.json(user, { status: 200 });
}),
Effect.catchTags({
UserNotVerified: (error) =>
HttpServerResponse.json(
{ error: "user_verified" },
{ status: 403 },
),
}),
),
),
);
Dans ce cas, UserNotFound fait partie du flux attendu. Les experts métier savent qu’elle peut survenir au cours du parcours.
UserNotFound est un peu générique dans cet exemple, mais on peut facilement imaginer de vraies erreurs spécifiques dans des domaines riches.
On les appelle des erreurs recoverable car on attend de l’appelant qu’il soit capable de les gérer, ou de les propager
à quelqu’un qui le peut.
Unrecoverable errors : les defects
Ce sont des erreurs purement techniques, des impasses. Le consommateur ne peut rien en faire : aucune alternative n’est possible, le système est dans un état illégal. Une connexion à la base de données qui tombe, une network partition, un invariant rompu : personne, dans la chaîne d’appels, n’a de réponse métier à y apporter.
Quand une erreur est un defect, il faut couper court à l’exécution. L’enrober dans un type pour la faire transiter de couche en couche ne fait qu’imposer à chaque intermédiaire de la connaître, pour rien, puisqu’aucun d’eux n’est censé ni capable de la traiter.
Un très bon exemple de ce type d’erreur est la configuration au runtime. Imaginons une configuration au runtime dont la présence est requise. Si elle est absente, vous ne pouvez pas démarrer l’application. C’est un defect car vous ne pouvez pas vous en remettre : il faut échouer immédiatement (fail fast) et laisser le système crasher.
import { Effect } from "effect";
export const startApplication = Effect.gen(function* () {
// ^ Effect.Effect<string, never> "never" car seul un defect peut survenir ici
const url = yield* Config.get("DATABASE_URL");
// Plus tard...
yield* startHttpServer(config);
});
Si Config.get échoue, on ne peut pas démarrer l’application et on devrait simplement crasher.
On ne devrait rien attendre d’autre ici. Court-circuiter l’exécution est la bonne chose à faire,
d’où le fait qu’aucune erreur typée ne devrait apparaître dans Config.get, sinon on délègue à l’appelant la responsabilité de traiter l’erreur.
Et c’est le moment où vous vous demandez en quoi c’est différent de simplement lever des exceptions. L’argument est simple. Les exceptions imitent le comportement au type-level qu’on attend des defects, c’est-à-dire qu’elles ne polluent pas le type system. Mais une exception à elle seule ne suffit pas à nous donner la garantie dont on a besoin. Il est très facile de catch une exception et de l’ignorer involontairement, puisqu’on peut aussi avoir des blocs try/catch pour traiter des erreurs attendues. Autrement dit, on utilise le même mécanisme et le même canal pour gérer les erreurs attendues et les erreurs inattendues, ce qui est très fragile by design.
La distinction doit être offerte par l’outil
Un bon écosystème d’Errors as Values rend cette distinction explicite :
- Effect (TypeScript) sépare
Effect.fail(recoverable) deEffect.die(defect) ; - Rust a
panic!; - Go a
panic().
À l’inverse, toute bibliothèque qui prétend faire de l’Errors as Values sans permettre de distinguer les deux est, à mon sens, incomplète. neverthrow en est un bon exemple, qui met en lumière les limites de cette approche : elle pousse à tout typer, y compris ce qui ne devrait jamais l’être.
J’ai déjà vu d’énormes types Result combinant Domain Errors et Defects dans un même type, ce qui est un signe évident d’une mauvaise approche.
On pourrait être tenté de tricher, en utilisant ResultAsync.fromSafePromise puis en fournissant un Promise.reject pour que neverthrow crashe, mais c’est une solution très fragile : là encore, il suffit d’attraper l’exception et de l’avaler pour la contourner. Il nous faut des garanties plus fortes pour être sûr de ne pas avaler un defect.
Exemple concret : un request handler
Prenons un handler HTTP. La perte de connexion à la base de données ou à un service tiers est un defect (network partition). Le handler n’a pas à le savoir ni à le gérer : ce type d’erreur doit simplement se propager comme une exception globale, puis être traduit en HTTP 500 côté serveur, sans jamais polluer la signature du handler ni des couches métier.
import { Effect } from "effect";
// ✅ Le handler connaît les erreurs métier, et seulement elles.
type GoodHandler = Effect<User, UserNotFound | OrganizationNotFound>;
// ❌ Le handler ne peut rien faire des deux dernières erreurs :
// elles n'ont rien à faire dans sa signature.
type BadHandler = Effect<User, UserNotFound | DbConnectionLost | NetworkFailed>;
Dans le second cas, DbConnectionLost et NetworkFailed forcent chaque couche traversée à déclarer et propager des erreurs qu’elle ne traitera jamais. Le contrat ment : il annonce une responsabilité qui n’existe pas.
Conclusion
Typer une erreur, ce n’est pas une formalité technique : c’est établir un contrat et une responsabilité. Cela revient à affirmer : « le consommateur peut et doit y réagir ». Si cette affirmation n’a pas de sens pour une erreur donnée, alors ce n’est pas une erreur récupérable : c’est un defect, et sa place n’est pas dans vos types, mais dans une exception qu’on laisse remonter pour couper court.
Les Errors as Values restent un outil formidable. À condition de ne pas oublier que leur but n’est pas de tout représenter, mais de représenter ce qui mérite de l’être.