← Retour au blog
TypeScript Comparaison entre erreurs Recoverable (Domain errors typées : Effect.fail, UserNotFound, ValidationFailed, Forbidden, qui entrent dans la signature) et Defect (hors-type : Effect.die, DbConnectionLost, NetworkPartition, propagées en exception globale, traduites en HTTP 500).

Errors as Values : erreurs typées vs defects

Toutes les erreurs n'ont pas vocation à être typées. Distinguer les erreurs récupérables (domain errors) des defects techniques, avec Effect, Rust et Go.

📅 ✍️ Antoine Coulon
effecterror-handlingtypescripterrors-as-valuestype-safety

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.

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.

La distinction doit être offerte par l’outil

Un bon écosystème d’Errors as Values rend cette distinction explicite :

À 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 (coucou neverthrow en TypeScript) : elle pousse à tout typer, y compris ce qui ne devrait jamais l’être.

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.

// ✅ Le handler connaît les erreurs métier, et seulement elles.
type Handler = Effect<User, UserNotFound | OrganizationNotFound>;
// ❌ Le handler ne peut rien faire des deux dernières erreurs :
//    elles n'ont rien à faire dans sa signature.
type Handler = 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.