Gestion des erreurs en TypeScript : apprendre de Go et Rust
La gestion des erreurs est l’un de ces sujets qui suscitent souvent des débats entre développeurs. Chaque langage a sa propre philosophie : JavaScript utilise les exceptions, Go repose sur les retours d’erreur explicites, et Rust adopte l’énumération Result. TypeScript, qui repose sur JavaScript, hérite des exceptions mais nous permet aussi de concevoir des schémas plus sûrs et plus prévisibles.
Cet article est en deux parties. Dans cette premiere partie, je vais parcourir la gestion des erreurs en TypeScript, la comparer à Go et Rust, et montrer comment importer le modèle Result de Rust dans TypeScript pour obtenir un code plus clair, testable et réutilisable.
Dans la seconde partie, nous verrons un usage avancé de ce pattern et comment en tirer avantage pour faciliter le test et l’écriture de mocks.
Gestion des erreurs en TypeScript
Par défaut, TypeScript (comme JavaScript) utilise les exceptions. Une fonction qui rencontre un problème lève (throw) une exception, et l’appelant doit se souvenir de l’envelopper dans un try/catch. Cette approche est simple et profondément ancrée dans l’écosystème JavaScript.
Cependant, son principal défaut est que la possibilité d’une erreur n'apparaît pas dans la signature de type de la fonction.
De l’extérieur, rien n’indique à l’appelant que la fonction ci-dessous divide peut lever une exception.
function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
Ce décalage force les développeurs à se reposer sur la documentation, les conventions ou leur intuition pour se souvenir des cas où des exceptions peuvent se produire.
Résultat : les bases de code contiennent souvent des flux de contrôle cachés, où les erreurs remontent de façon inattendue, compliquant le débogage et la maintenance.
Même si il est vrai que TypeScript permet d’annoter une fonction avec un type de throw en plus de son type de retour, comme par exemple: (): number throws Error cela reste essentiellement descriptif. Le compilateur n’impose pas une gestion cohérente via try/catch, et le support dans les outils (tel que ESLint ou Typescript type checker) reste limité.
Qui plus est, ce mécanisme ne permet pas d’unifier la manière dont les fonctions exposent leurs réussites ou leurs échecs. Certaines renverront une valeur, d’autres lèveront une exception, et l’appelant devra jongler avec plusieurs façons de gérer les erreurs.
Ce manque d’interface unique et prévisible pour les résultats de fonction est la plus grande faiblesse de l’approche basée uniquement sur les exceptions en TypeScript.
Gestion des erreurs en GO
Go adopte une position très différente: les fonctions renvoient explicitement des erreurs, généralement comme dernière valeur de retour.
result, err := divide(10, 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
Cette approche a l’avantage d’intégrer la gestion des erreurs dans le contrat de la fonction et on ne peut pas ignorer la possibilité d’une erreur, car elle est toujours présente dans le type de retour.
Chaque fonction en Go susceptible d’échouer suit la même convention (result, error), créant ainsi une interface unifiée dans tout le langage. Cette uniformité rend le code prévisible : en lisant ou en écrivant du Go, vous savez immédiatement comment gérer les erreurs, sans avoir besoin de vérifier la documentation ou de deviner.
L’inconvénient est la verbosité. Les appels imbriqués nécessitent souvent des vérifications répétitives if err != nil, ce qui peut encombrer le code et donner un côté mécanique. Cependant, de nombreux développeurs Go estiment que cette explicitation vaut largement le coût.
Gestion des erreurs en RUST
Rust pousse encore plus loin l’idée des erreurs explicites avec son type Result :
enum Result<T, E> {
Ok(T),
Err(E),
}
Chaque fonction susceptible d’échouer renvoie un Result, et le compilateur impose à l’appelant de traiter à la fois les cas de succès et d’échec. Impossible donc d’ignorer une erreur : Rust ne laissera pas compiler tant que tous les cas ne sont pas gérés.
Cela conduit à un code extrêmement robuste : les erreurs sont modélisées comme des valeurs, au même titre que le reste du programme.
Le modèle encourage la gestion exhaustive via match, de sorte que les développeurs considèrent naturellement tous les résultats possibles d’un calcul. L’avantage principal réside dans la combinaison de sécurité et d’expressivité.
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("Division by zero".to_string())
} else {
Ok(a / b)
}
}
fn main() {
let result = divide(10, 0);
match result {
Ok(value) => println!("Result: {}", value),
Err(e) => println!("Error: {}", e),
}
}
Plutôt que de se reposer sur des conventions (comme Go) ou sur des exceptions à l’exécution (comme JavaScript), Rust intègre directement la gestion des erreurs dans son système de types.
L’inconvénient est que pour des opérations très simples, le fait d’envelopper systématiquement dans Ok ou Err peut sembler lourd. Mais dans des systèmes complexes, la fiabilité obtenue compense largement ce coût.
Importer le modèle Rust dans TypeScript
Pour tirer parti de l’approche de Rust, nous allons modéliser Result avec une union discriminée en TypeScript.
L’union aura un discriminateur booléen stable (ok) afin que TypeScript puisse affiner les types de manière fiable, et un champ value ou error qui contiendra respectivement la valeur ou l’erreur.
export type Result<T = void, E extends Error = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Le type T est par défaut void et le type E par défaut Error, ce qui permet de déclarer simplement des fonctions void sans classe d’erreur spécifique.
function simpleVoidFct(success: boolean): Result {
// some code
}
Nous ajoutons de simples constructeurs utilitaires Ok et Err pour éviter que les appelants construisent les objets à la main (ce qui limite les erreurs et préserve l’inférence de types).
export function Ok<T = void>(value?: T): Result<T, never> {
return { ok: true, value: value as T };
}
export function Err<E extends Error>(e: E | string): Result<never, E> {
return { ok: false, error: e instanceof Error ? e : new Error(e) };
}
Grâce à la valeur par défaut T = void et au forçage du type T sur value, Ok() peut être appelé sans argument pour représenter un succès sans valeur, et Err() accepte un argument de type Error ou string, ce qui simplifie grandement leur utilisation comme illustré dans l’exemple ci-dessous:
function simpleVoidFct(success: boolean): Result {
if (success) {
return Ok(); // no payload needed, Ok<void> is inferred
}
return Err("Something went wrong"); // Error instance is created from the string
}
Maintenant il est facile de décrire le type d’un retour de fonction:
// here divide contract is: return a number or an error of type Error
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return Err("Division by zero");
}
return Ok(a / b);
}
const result = divide(10, 0);
// testing the result becomes easy and straightforward
if (!result.ok) {
console.error("Error:", result.error.message);
} else {
console.log("Result:", result.value);
}
TypeScript imposera une vérification stricte : si vous écrivez Ok() dans une fonction qui doit renvoyer une valeur (Result<number>), le compilateur se plaindra — ce qui est une bonne chose.
Utiliser une classe d’erreur personnalisée
Le pattern supporte l’utilisation de classes d’erreur personnalisées. Dans l’exemple ci-dessous nous créons une classe DivisionError pour l’utiliser dans divide:
class DivisionError extends Error {
constructor(message: string) {
super(message);
this.name = "DivisionError";
}
}
La fonction divide devient donc maintenant:
function divide(a: number, b: number): Result<number, DivisionError> {
if (b === 0) {
return Err(new DivisionError("Division by zero"));
}
return Ok(a / b);
}
le type Result<number, DivisionError> décrit clairement le contrat. On sait exactement quel genre d’erreur peut sortir et l’exploiter dans le code client:
const result = divide(10, 0);
if (!result.ok) {
console.error("Error:", result.error.message);
if (result.error instanceof DivisionError) {
console.error("Error name:", result.error.name);
}
} else {
console.log("Result:", result.value);
}
Promises et flux asynchrones
La gestion d’erreurs sur les opérations d’I/O est un cas typique où les promesses peuvent échouer de multiples façons : fichier manquant, permissions insuffisantes, corruption de données, etc.
Avec Node.js, la méthode fs.readFile par example, est asynchrone et rejette sa promesse en cas d’erreur. Mais en combinant await avec un try/catch, et en retournant notre type Result, on rend la gestion d’erreur explicite tout en gardant un flux asynchrone propre :
import { promises as fs } from "fs";
async function readFileContent(path: string): Promise<Result<string>> {
try {
const data = await fs.readFile(path, "utf-8");
return Ok(data);
} catch (e: any) {
return Err(e);
}
}
const result = await readFileContent("./data.txt");
if (!result.ok) {
console.error("Failed to read file:", result.error.message);
} else {
console.log("File content:", result.value);
}
Le point clé ici, c’est que la fonction a pour type de retour Promise<Result<string, Error>> (dans l’exemple ci-dessus le type Error n’est pas mentionné dans le type de retour car inféré par le compilateur).
Le mot-clé await résout la promesse renvoyée par fs.readFile, et la fonction readFileContent étant elle-même déclarée async, elle renvoie toujours une promesse. À l’intérieur de cette promesse, on encapsule soit le contenu du fichier, soit l’erreur interceptée, dans un Result.
De cette manière, les échecs asynchrones sont gérés aussi proprement que les erreurs synchrones.
Le code appelant n’a plus besoin d’un try/catch autour de chaque appel : il se contente de tester le Result, maintenant ainsi une interface de gestion d’erreur cohérente entre fonctions synchrones et asynchrones.
Conclusion de la partie 1
Le modèle d’exceptions de JavaScript est simple mais peut cacher les erreurs. Go rend les erreurs explicites avec une interface uniforme de retour, au prix d’une certaine verbosité. Rust impose la gestion des erreurs au niveau du système de types, créant des programmes extrêmement sûrs et prévisibles.
Avec TypeScript, nous n’avons pas les garanties du compilateur Rust, mais nous pouvons adopter le même modèle que Rust grâce aux unions discriminées. Le résultat est un code plus sûr, plus prévisible et plus facile à tester.
A suivre …
Maintenant que nous avons vu les bases du modèle Result et ses principaux exemples, la deuxième partie montrera son intérêt dans des cas d’usage plus complexes — en enchaînant plusieurs opérations, en gérant des erreurs personnalisées et en simplifiant l’écriture ainsi que la maintenance des tests unitaires.






