La signature de votre fonction dit qu’elle retourne un User. C’est faux. Elle retourne un User ou elle explose. Le système de types ne connaît simplement pas la seconde branche.
C’est le manque d’honnêteté fondamental de la gestion des erreurs par exceptions. Chaque throw est un chemin de flux de contrôle que votre compilateur ne peut pas voir, ne peut pas vérifier et ne peut pas imposer. Vous finissez avec du code qui passe la vérification des types parfaitement et plante tout de même en production parce que quelqu’un a oublié un try/catch trois couches d’appel plus haut.
Les retours d’erreur explicites corrigent cela. Ils font de l’échec un citoyen de premier ordre de votre système de types. La question est de savoir si le coût en ergonomie vaut l’honnêteté.
Ce que la correction au niveau des types signifie pour les erreurs
La correction au niveau des types signifie que vos types disent la vérité sur ce qu’une fonction peut produire. Si une fonction peut échouer, le type de retour doit le dire. Si elle ne peut pas, le type de retour doit garantir le succès.
Rust encode cela avec Result<T, E> :
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse()
}
La signature est complète. Les appelants savent qu’il y a deux résultats possibles. Le compilateur les force à gérer les deux.
TypeScript, par défaut, ne le fait pas. Une fonction qui lance une exception ressemble à une qui n’en lance pas :
function parsePort(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
throw new Error(`Invalid port: ${s}`);
}
return n;
}
Le type de retour dit number. Il devrait dire number | Error, ou plus exactement, Result<number, Error>. Mais ce n’est pas le cas, donc les appelants n’ont aucun signal leur indiquant qu’ils doivent se défendre contre l’échec.
Comment les erreurs lancées se cachent dans votre graphe d’appels
Les dégâts s’accumulent à mesure que votre pile d’appels s’approfondit. Une erreur lancée dans une fonction feuille force chaque couche intermédiaire à soit l’intercepter, soit la propager implicitement. Aucun de ces chemins de propagation n’apparaît dans une signature de type.
Imaginez un config loader qui appelle un parser qui appelle un validateur. Si le validateur lance une exception, le parser et le loader deviennent aussi susceptibles de lancer des exceptions. Mais leurs types ne changent pas. Vous ne pouvez pas regarder loadConfig() et savoir qu’il peut échouer. Vous devez lire chaque ligne de chaque dépendance, ou attendre un plantage à l’exécution.
C’est exactement l’inverse de la façon dont les types sont censés fonctionner. Les types existent pour que vous n’ayez pas besoin de lire l’implémentation pour comprendre le contrat.
Les retours d’erreur explicites inversent cela. Le chemin d’échec est visible à chaque couche :
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
return { ok: false, error: `Invalid port: ${s}` };
}
return { ok: true, value: n };
}
function loadConfig(raw: Record<string, string>): Result<Config, string> {
const portResult = parsePort(raw.port);
if (!portResult.ok) return portResult;
const host = raw.host || "localhost";
return { ok: true, value: { port: portResult.value, host } };
}
Maintenant, la signature de loadConfig raconte toute l’histoire. Vous savez qu’elle peut échouer avant d’avoir lu une seule ligne du corps.
Le vrai compromis est l’ergonomie, pas l’expressivité
L’approche honnête est plus verbeuse. Personne n’aime écrire { ok: false, error: ... } au lieu de throw new Error(...). Le bruit visuel s’accumule, surtout quand vous enchaînez plusieurs opérations sujettes à l’échec.
C’est pourquoi la plupart des langages avec exceptions vérifiées les ont abandonnées. Les exceptions vérifiées de Java tentaient d’imposer une gestion explicite au niveau des types, mais la syntaxe était punitive. L’opérateur ? de Rust et les combinateurs Result comme map et and_then récupèrent la majeure partie de cette ergonomie. TypeScript n’a pas d’opérateur ?, mais vous pouvez vous en approcher avec des fonctions utilitaires :
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? { ok: true, value: fn(result.value) } : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
C’est toujours plus maladroit que Rust. Si vous écrivez du TypeScript, des bibliothèques comme neverthrow fournissent un type Result avec des méthodes chaînables qui se sentent presque natives. Le surcoût est réel, mais il est plus petit qu’il n’y paraît une fois que vous avez des utilitaires en place.
Il y a un autre coût. Les stack traces des erreurs lancées sont riches et automatiques. Les erreurs retournées ne sont que des valeurs. Si vous voulez une stack trace, vous devez la construire vous-même. Pour le débogage, cela compte. Pour la gestion prévisible des erreurs dans la logique métier, ce n’est généralement pas le cas.
Quand lancer des exceptions a encore du sens
Nous ne prônons pas zéro exception. Certains échecs sont vraiment exceptionnels et ne devraient pas faire partie de votre modèle d’erreur normal. Un fichier manquant lors du chargement de la configuration est un échec attendu. Une erreur de manque de mémoire ne l’est pas.
La règle empirique : si un échec fait partie de votre domaine, retournez-le. Si c’est un bug de programmation ou une défaillance système catastrophique, lancez-le. Le fait que JSON.parse lance une exception sur une entrée malformée est ennuyeux car du JSON invalide est courant. Le fait que Array.prototype.map lance une exception sur un tableau null est acceptable car appeler .map sur null est un bug.
En pratique, cela signifie que vos frontières I/O, vos parsers, vos validateurs et vos règles métier devraient retourner des erreurs. Vos invariants, assertions et conditions vraiment catastrophiques peuvent toujours être lancées.
Commencez par les frontières de votre API
Vous n’avez pas besoin de refactoriser l’intégralité de votre base de code. Les endroits à plus haute valeur pour adopter les retours d’erreur explicites sont vos API publiques et vos couches I/O. Ce sont les frontières où les appelants ont besoin de savoir ce qui peut mal tourner.
Choisissez un module. Changez ses types de retour en Result<T, E>. Mettez à jour ses appelants. Voyez ce que cela donne. Le pattern est addictif d’une manière particulière : une fois que vous arrêtez de chercher dans les stack traces des blocs try/catch manquants, vous réalisez combien de surcharge mentale les exceptions lancées vous coûtaient.
La correction au niveau des types pour les erreurs ne consiste pas à écrire du code parfait. Il s’agit d’écrire du code qui ment moins.