Vous utilisez mal les exceptions

Vous pourriez être tentés d’écrire ceci :
const divide = (a: number, b: number): number => {
if (b === 0) {
throw Error("Division by 0.");
}
return a / b;
};
Bien que simple, cette fonction est très problématique. Des problèmes, j’en vois au moins 3 :
-
Cette fonction n’est pas « honnête ». En effet, rien dans sa signature
(a: number, b: number) => numbern’indique qu’elle peut lancer une exception, c’est-à-dire produire un side effect. Il faut lire l’implémentation pour le savoir… merci TypeScript 😉 -
D’autre part, si cette fonction n’est pas juste un pur utilitaire en dehors de tout contexte métier, on pourrait vouloir donner plus de sens aux paramètres
aetb, et encapsuler les primitives. Sibn’est plus de typenumbermaisDenominator, ce serait probablement à lui d’empêcher la création d’un dénominateur nul. -
Mais, plus profondément, la gestion des erreurs pose un problème sémantique. Le cas-limite de la division par 0 est totalement anticipable. Vous n’avez donc aucune raison, pour le traiter, de lancer une exception, mécanisme censé rester… exceptionnel. Oui, lancer une exception, c’est reconnaître que votre application est en PLS, qu’elle ne sait plus gérer.
Autrement dit, vous souhaitez communiquer auprès de l’appelant, possiblement l’utilisateur final, pour qu’il modifie son comportement. Tandis qu’avec une exception, c’est aux développeurs que vous parlez, pour leur dire qu’il y a un bug dans l’application et qu’ils doivent le corriger sans trop tarder s’il vous plaît !
Vous devez donc trouver autre chose pour gérer ce type d’erreurs.
Ce que nous voulons, c’est un type, appelons-le Result, qui nous permette de représenter aussi bien le cas nominal, où la division peut avoir lieu (cas Ok) que le cas-limite où la division ne peut être effectuée parce que b est nul (cas Ko).
De surcroît, nous souhaitons que ce type nous rende service en exposant une fonction bind qui permettra :
- Dans le cas passant (
Ok), d’effectuer d’autres traitements à partir du résultat de la division ; - Dans le cas non-passant (
Ko), de faire suivre l’erreur, puisqu’il n’y a rien à faire de plus.
C’est ce que l’on appelle du polymorphisme : les implémentations ok() et ko() satisfont toutes deux le contrat exposé par le type Result, mais chacune à sa manière, puisque dans un cas on a une valeur, dans l’autre une erreur.
Saperlipopette, que cela est intéressant !
En effet, nous pouvons à présent chaîner les instructions, et écrire aussi bien divide(1, 2).bind(square) que divide(1, 0).bind(square). Les erreurs éventuelles ne mettent plus le programme en échec, la fonction divide: (a: number, b: number) => Result<number, DivisionByZeroError> dit bien ce qu’elle fait, tout le monde est content.
Il ne nous reste plus qu’à ajouter une méthode finally pour produire des effets de bord en bout de chaîne, ce qui, dans un contrôleur HTTP, par exemple, se traduirait ainsi :
Ok-> code 200Ko-> code 422 (Unprocessable Content)
« Quid des opérations asynchrones ? » me direz-vous. Ce cas requiert un peu plus de travail, mais l’idée reste simple : composer Result et Promise pour manipuler un type Promise<Result> et ne plus utiliser Promise.reject, puisque Promise.resolve manipule un type Result qui représente déjà les cas passant et non-passant.
Vous pouvez introduire ces quelques lignes de code dans votre codebase, elles ont le mérite d’être simples et permettent à chacun de comprendre ce qu’il se passe concrètement. Ou bien vous pouvez faire appel à une bibliothèque de code, la référence dans l’univers TypeScript étant Neverthrow.
En tout cas bravo, car vous avez créé votre première Monade !