Result et Option : gestion des erreurs à la Rust

1. Aperçu

Rust n’a pas d’exception comme Java ou C#. À la place, Rust utilise deux types de données Option<T> et Result<T, E> pour représenter « une valeur qui peut être absente » et « une opération qui peut échouer ». Cet article vous aide à comprendre comment utiliser ces deux types correctement, comment propager les erreurs élégamment avec l’opérateur ?, et comment définir des erreurs personnalisées avec thiserror. À la fin, vous serez capable d’écrire du code Rust qui gère les erreurs de façon explicite, sans avoir besoin de try/catch.

2. Pourquoi pas d’exception ?

En Java, une méthode peut lancer une RuntimeException à tout moment sans que la signature ne l’indique. Vous devez lire la Javadoc ou le code pour savoir ce qu’elle peut lever.

Rust vous oblige à déclarer la possibilité d’échec directement dans le type de retour :

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        return Err("division par zéro".to_string());
    }
    Ok(a / b)
}

En voyant la signature Result<i32, String>, vous savez immédiatement que cette fonction peut échouer. Le compilateur vous oblige à traiter à la fois la branche Ok et la branche Err, il n’est pas possible d’ignorer silencieusement une erreur comme oublier un catch en Java.

Note : Rust dispose toujours de panic! pour les erreurs irrécupérables (bugs dans le code, violation d’invariants). Result est réservé aux erreurs prévisibles et traitables.

3. Option : une valeur qui peut être absente

Option<T> remplace null. Il a deux variantes : Some(T) et None.

fn find_customer(id: u32) -> Option<String> {
    let customers = vec![(1, "Alice"), (2, "Bob")];
    customers.iter()
        .find(|(cid, _)| *cid == id)
        .map(|(_, name)| name.to_string())
}

fn main() {
    match find_customer(2) {
        Some(name) => println!("Trouvé : {name}"),
        None => println!("Aucun client avec cet ID"),
    }
}

find retourne Option<&(u32, &str)>, map transforme la valeur à l’intérieur de Some si elle existe, l’ignore si c’est None. Le résultat :

Trouvé : Bob

3.1. Méthodes courantes

let maybe_age: Option<u32> = Some(25);

let age = maybe_age.unwrap_or(18);          // 25, ou 18 si None
let doubled = maybe_age.map(|a| a * 2);     // Some(50)
let result: Result<u32, &str> = maybe_age.ok_or("âge manquant"); // Ok(25)
  • unwrap_or(default) : extrait la valeur ou retourne la valeur par défaut, ne paniquera jamais.
  • map(f) : transforme la valeur à l’intérieur si elle existe, laisse None inchangé.
  • ok_or(err) : convertit Option<T> en Result<T, E> — utile quand vous avez besoin d’utiliser l’opérateur ? ensuite.

Note : Évitez d’appeler .unwrap() directement sur Option/Result dans le code en production. Cela provoque une panique dès qu’on rencontre None/Err, équivalent à lever une NullPointerException incontrôlée. Utilisez-le uniquement dans les tests ou quand vous êtes sûr à 100 % que la valeur existe.

4. Result<T, E> : succès ou échec

Result<T, E> a deux variantes : Ok(T) pour le succès, Err(E) pour l’échec.

use std::num::ParseIntError;

fn parse_quantity(input: &str) -> Result<u32, ParseIntError> {
    input.trim().parse::<u32>()
}

fn main() {
    match parse_quantity("  42 ") {
        Ok(qty) => println!("Quantité : {qty}"),
        Err(e) => println!("Erreur parse : {e}"),
    }
}

parse::<u32>() retourne Result<u32, ParseIntError>. Le match vous oblige à traiter à la fois la branche de succès et la branche d’erreur — le compilateur refuse de compiler si une branche manque.

Quantité : 42

4.1. L’opérateur ? pour propager les erreurs

Écrire du match partout est très verbeux. L’opérateur ? permet de propager une erreur immédiatement si on rencontre un Err, similaire à un throw automatique :

use std::num::ParseIntError;

struct Order {
    quantity: u32,
    price: u32,
}

fn parse_order(qty_str: &str, price_str: &str) -> Result<Order, ParseIntError> {
    let quantity = qty_str.trim().parse::<u32>()?;
    let price = price_str.trim().parse::<u32>()?;
    Ok(Order { quantity, price })
}

fn main() {
    match parse_order("3", "15000") {
        Ok(order) => println!("Total : {}", order.quantity * order.price),
        Err(e) => println!("Données invalides : {e}"),
    }
}

La ligne qty_str.trim().parse::<u32>()? signifie : si parse retourne un Err, la fonction parse_order retourne immédiatement cet Err à l’appelant, pas besoin de match manuel. Si c’est Ok, la valeur à l’intérieur est « extraite » et assignée à quantity.

Total : 45000

Note : L’opérateur ? ne fonctionne que quand le type d’erreur de la fonction actuelle et de la fonction appelée sont compatibles (ou qu’il existe une implémentation From pour les convertir). Sinon, le compilateur signale une erreur de type mismatch à la compilation.

5. Combiner Option et Result

Ces deux types se rencontrent souvent. Par exemple, trouver un produit en stock puis vérifier si la quantité est suffisante :

struct Product {
    name: String,
    stock: u32,
}

fn find_product(name: &str, products: &[Product]) -> Option<&Product> {
    products.iter().find(|p| p.name == name)
}

fn check_stock(name: &str, requested: u32, products: &[Product]) -> Result<(), String> {
    let product = find_product(name, products)
        .ok_or_else(|| format!("Produit non trouvé : {name}"))?;

    if product.stock < requested {
        return Err(format!("Seulement {} produits restants", product.stock));
    }
    Ok(())
}

ok_or_else convertit Option<&Product> en Result<&Product, String> avec un message d’erreur, puis ? propage si c’est None. Cette approche évite le match imbriqué sur plusieurs niveaux.

6. Définir des erreurs personnalisées avec thiserror

Utiliser String comme type d’erreur convient pour de petits exemples, mais un vrai projet a besoin d’une enum d’erreur structurée. La crate thiserror rend cela élégant :

<!-- Cargo.toml équivalent, utiliser la section dependencies -->
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;

#[derive(Error, Debug)]
enum OrderError {
    #[error("produit n'existe pas : {0}")]
    ProductNotFound(String),

    #[error("stock insuffisant, {available} produits restants")]
    InsufficientStock { available: u32 },
}

fn validate_order(stock: Option<u32>, requested: u32) -> Result<(), OrderError> {
    let available = stock.ok_or_else(|| OrderError::ProductNotFound("Laptop".into()))?;
    if available < requested {
        return Err(OrderError::InsufficientStock { available });
    }
    Ok(())
}

#[derive(Error, Debug)] génère automatiquement l’implémentation du trait std::error::Error. #[error("...")] définit le message affiché pour chaque variante, avec support des placeholders comme {0} (positional) ou {available} (nom du champ). Le résultat quand on affiche l’erreur :

Error: InsufficientStock { available: 2 }

Si vous utilisez {} au lieu de {:?} lors de l’affichage, vous obtenez le message convivial : stock insuffisant, 2 produits restants.

7. anyhow pour les applications, thiserror pour les bibliothèques

Quand vous écrivez un binaire/une application (pas une bibliothèque pour d’autres), vous n’avez généralement pas besoin de distinguer les types d’erreurs en détail — juste les propager et les afficher. La crate anyhow convient parfaitement :

[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| format!("impossible de lire le fichier config : {path}"))
}

fn main() -> Result<()> {
    let config = load_config("app.toml")?;
    println!("Config : {config}");
    Ok(())
}

anyhow::Result<T> est un alias de Result<T, anyhow::Error>, qui peut contenir n’importe quelle erreur implémentant le trait Error. with_context ajoute du contexte à l’erreur originale, ce qui facilite le débogage sans avoir à définir votre propre enum.

  • Bibliothèque (une crate que d’autres vont use) : utilisez thiserror, retournez une enum d’erreur spécifique pour que l’appelant puisse match chaque cas.
  • Application (binaire final) : utilisez anyhow, propagez simplement et loggez, personne n’a besoin de match en détail.

8. Tester le comportement des erreurs

Rust a un framework de test intégré, pas besoin de bibliothèque externe pour les tests basiques.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn retourne_ok_quand_stock_suffisant() {
        let result = validate_order(Some(10), 5);
        assert!(result.is_ok());
    }

    #[test]
    fn retourne_err_quand_stock_insuffisant() {
        let result = validate_order(Some(2), 5);
        assert!(matches!(
            result,
            Err(OrderError::InsufficientStock { available: 2 })
        ));
    }

    #[test]
    fn retourne_err_quand_produit_non_trouve() {
        let result = validate_order(None, 5);
        assert!(matches!(result, Err(OrderError::ProductNotFound(_))));
    }
}

#[test] marque une fonction comme test, lancez-la avec cargo test. matches! est une macro qui vérifie si une valeur correspond à un pattern donné — plus pratique que assert_eq! pour comparer des enums avec des champs, car vous n’avez pas besoin d’implémenter PartialEq sur tout votre type d’erreur.

running 3 tests
test tests::retourne_ok_quand_stock_suffisant ... ok
test tests::retourne_err_quand_stock_insuffisant ... ok
test tests::retourne_err_quand_produit_non_trouve ... ok

9. Erreurs courantes

  • Abus de .unwrap() : le code fonctionne bien en dev, puis panique inopinément en production avec une entrée inattendue. Utilisez ?, match, ou unwrap_or_else avec gestion explicite.
  • Utiliser String comme type d’erreur dans une bibliothèque publique : l’appelant ne peut pas match par type d’erreur, seulement comparer des chaînes — facile à casser quand vous changez le message. Utilisez une enum avec thiserror au lieu de String.
  • Mélanger anyhow::Error dans l’API publique d’une bibliothèque : cela cache les informations de type d’erreur précis, l’appelant doit faire du downcast manuel. anyhow ne doit être utilisé qu’à la couche application finale.
  • Oublier #[derive(Debug)] : l’opérateur ? et beaucoup de macros de test exigent que le type d’erreur implémente Debug. L’oublier provoque une erreur de trait bound non satisfait du compilateur.

Note : Si vous venez de Java et voulez « émuler les checked exception », ne tentez pas de reproduire try/catch avec une macro. Habituez-vous à cette idée : les erreurs sont des valeurs (value), retournées et transmises comme n’importe quelle autre donnée.

10. Conclusion

Option<T> et Result<T, E> transforment la gestion de l’absence et de l’échec en une partie du système de types, vérifiée par le compilateur au moment de la compilation plutôt qu’à l’exécution. L’opérateur ? garde le code concis tout en restant explicite. Pour un petit projet, String ou anyhow suffisent ; pour une bibliothèque partagée, investissez dans une enum d’erreur claire avec thiserror pour que l’appelant traite chaque cas de manière sûre.

rustgestion-des-erreursoptionresultthiserror