C# · Le langage

Exceptions et gestion d'erreur

try, catch, finally, exceptions personnalisées, patron Result.

Vérifié en septembre 2026 · .NET 10, et .NET 11 RC 1 pour C# 15 · environ 15 min

Une exception interrompt le chemin normal et remonte la pile jusqu'au premier catch qui la veut ; si aucun ne la veut, le processus s'arrête. C'est le mécanisme d'erreur de .NET : le runtime et la bibliothèque standard s'en servent pour tout ce qui échoue. Le métier consiste à choisir quoi lever, où l'attraper, comment relancer sans perdre la trace, et à reconnaître les erreurs qui ne sont pas exceptionnelles du tout : un stock insuffisant ou une saisie invalide se traitent souvent mieux comme une valeur de retour que comme un saut dans la pile.

Lever la bonne exception

Toute exception dérive de System.Exception. Le type est ce que l'appelant attrape : il doit dire la nature de l'erreur, et le message l'expliquer à qui lira le journal. Les règles de conception de .NET interdisent de lever Exception ou SystemException, de lever ou de dériver ApplicationException, et de lever soi-même les exceptions que le runtime se réserve — NullReferenceException, IndexOutOfRangeException, StackOverflowException, OutOfMemoryException. Le type le plus général est une faute parce qu'il oblige l'appelant à écrire catch (Exception), qui attrape aussi les bogues et les fait passer pour l'erreur prévue. Les types standard couvrent l'essentiel, et les méthodes ThrowIf apparues entre .NET 6 et .NET 8 les lèvent en une ligne, avec le nom du paramètre dans le message et, pour les bornes, la valeur fautive.

TypeQuand le leverAssistant
ArgumentNullExceptionun argument nul là où il est interditThrowIfNull (.NET 6)
ArgumentOutOfRangeExceptionun argument hors de son domaineThrowIfNegative, ThrowIfGreaterThan… (.NET 8)
ArgumentExceptiontout autre argument invalideThrowIfNullOrEmpty (.NET 7), ThrowIfNullOrWhiteSpace (.NET 8)
InvalidOperationExceptionl'objet n'est pas dans un état qui permet l'appel—
ObjectDisposedExceptionl'objet a été libéré ; dérive d'InvalidOperationExceptionThrowIf (.NET 7)
NotSupportedExceptionl'opération n'existe pas pour ce type ou cette instance—

Le contre-exemple lève Exception pour une quantité invalide, et son catch (Exception) fait passer un bogue pour une erreur de saisie :

using System;
using System.Collections.Generic;

sealed class Stock
{
    private Dictionary<string, int>? _disponible;

    public void Charger() => _disponible = new() { ["C-001"] = 5 };

    public void Reserver(string reference, int quantite)
    {
        if (quantite <= 0)
        {
            // Exception, le type le plus general : pour attraper celle-ci,
            // l'appelant n'a pas d'autre choix que catch (Exception).
            throw new Exception("Quantite invalide.");
        }

        // Un bogue : si Charger n'a pas ete appele, _disponible est null.
        _disponible![reference] -= quantite;
    }
}

static class Caisse
{
    public static void Executer()
    {
        var stock = new Stock(); // Charger oublie

        foreach (var quantite in new[] { 0, 2 })
        {
            try
            {
                stock.Reserver("C-001", quantite);
            }
            catch (Exception)
            {
                Console.WriteLine($"{quantite} : quantite refusee, saisissez-en une autre");
            }
        }
        // 0 : quantite refusee, saisissez-en une autre
        // 2 : quantite refusee, saisissez-en une autre
        // La seconde ligne est une NullReferenceException, deguisee en saisie.
    }
}
using System;
using System.Collections.Generic;

sealed class Stock
{
    private Dictionary<string, int>? _disponible;

    public void Charger() => _disponible = new() { ["C-001"] = 5 };

    public void Reserver(string reference, int quantite)
    {
        // Un argument invalide : l'appelant s'est trompe. Les methodes ThrowIf
        // lisent le nom du parametre dans l'expression passee.
        ArgumentException.ThrowIfNullOrWhiteSpace(reference);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantite);

        // L'objet n'est pas dans un etat qui permet l'appel.
        if (_disponible is null)
        {
            throw new InvalidOperationException("Le stock n'est pas charge : appelez Charger d'abord.");
        }

        _disponible[reference] -= quantite;
    }
}

static class Caisse
{
    // Pour l'affichage de l'exemple seulement : aucune de ces exceptions n'est
    // faite pour etre attrapee par l'appelant, qui doit corriger son code.
    static void Afficher(Action appel)
    {
        try
        {
            appel();
        }
        catch (Exception ex)
        {
            Console.WriteLine($"{ex.GetType().Name} : {ex.Message}");
        }
    }

    public static void Executer()
    {
        var stock = new Stock();

        Afficher(() => stock.Reserver("C-001", 0));
        // ArgumentOutOfRangeException : quantite ('0') must be a non-negative and non-zero value. (Parameter 'quantite')
        // Actual value was 0.
        Afficher(() => stock.Reserver(" ", 2));
        // ArgumentException : The value cannot be an empty string or composed entirely of whitespace. (Parameter 'reference')
        Afficher(() => stock.Reserver("C-001", 2));
        // InvalidOperationException : Le stock n'est pas charge : appelez Charger d'abord.
    }
}

Une exception d'argument signale une erreur de programmation chez l'appelant. Elle ne s'attrape pas pour réessayer : elle se corrige, et c'est à l'appelant de valider une saisie avant de la transmettre.

try, catch, finally

Les clauses catch sont examinées dans l'ordre, et la première dont le type convient gagne : on les écrit de la plus précise à la plus générale, et, sans filtre pour les distinguer, le compilateur refuse l'inverse (CS0160). Le finally s'exécute quelle que soit la sortie du try : fin normale, return, exception. Seul un arrêt immédiat du processus l'en empêche, comme Environment.FailFast ou un dépassement de pile. Pour une exception que rien n'attrape, la documentation ne garantit rien et le fait dépendre du système ; .NET 10 sous Windows x64 l'exécute après avoir affiché l'erreur. L'instruction using est un try/finally que le compilateur écrit : Dispose s'exécute en quittant le bloc, donc avant le catch qui l'entoure.

using System;

sealed class Connexion : IDisposable
{
    public void Dispose() => Console.WriteLine("Dispose");
}

static class Ordre
{
    static int Lire(string texte)
    {
        try
        {
            Console.WriteLine("try");
            return int.Parse(texte);
        }
        // Un catch (Exception) place avant celui-ci : CS0160, le compilateur
        // refuse un catch qu'un precedent rend inatteignable.
        catch (FormatException)
        {
            Console.WriteLine("catch");
            return -1;
        }
        finally
        {
            // Apres le return du try comme apres celui du catch.
            Console.WriteLine("finally");
        }
    }

    public static void Executer()
    {
        Console.WriteLine(Lire("42"));
        // try
        // finally
        // 42
        Console.WriteLine(Lire("x"));
        // try
        // catch
        // finally
        // -1

        try
        {
            // using : un try/finally ecrit par le compilateur, qui appelle
            // Dispose en sortant du bloc, exception ou non.
            using var connexion = new Connexion();
            throw new TimeoutException("Delai depasse.");
        }
        catch (TimeoutException ex)
        {
            Console.WriteLine(ex.Message);
        }
        // Dispose
        // Delai depasse.
    }
}

Le piège du finally est d'y lever. Une exception levée pendant qu'une autre remonte la remplace purement et simplement : la première n'est ni chaînée ni journalisée, et c'est pourtant elle qui dit ce qui s'est passé. L'analyseur CA2219 signale un throw écrit dans un finally, mais il n'est actif par défaut que comme simple suggestion : visible dans l'IDE, muet dans dotnet build. Il ne voit pas, comme ici, une méthode appelée qui lève.

using System;
using System.IO;

sealed class Fichier
{
    public void Ecrire(string ligne) => throw new IOException("Disque plein.");

    public void Fermer() => throw new InvalidOperationException("Fichier deja ferme.");
}

static class Export
{
    public static void Executer()
    {
        var fichier = new Fichier();
        try
        {
            try
            {
                fichier.Ecrire("C-001;5");
            }
            finally
            {
                // Leve pendant qu'une IOException est en cours : elle la remplace.
                fichier.Fermer();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"{ex.GetType().Name} : {ex.Message}");
        }
        // InvalidOperationException : Fichier deja ferme.
        // Le disque plein, cause reelle, n'apparait nulle part.
    }
}

Un nettoyage qui peut échouer se protège lui-même et laisse passer l'erreur d'origine.

using System;
using System.IO;

sealed class Fichier
{
    public void Ecrire(string ligne) => throw new IOException("Disque plein.");

    public void Fermer() => throw new InvalidOperationException("Fichier deja ferme.");
}

static class Export
{
    public static void Executer()
    {
        var fichier = new Fichier();
        try
        {
            try
            {
                fichier.Ecrire("C-001;5");
            }
            finally
            {
                // Le nettoyage se protege lui-meme : son echec se journalise
                // et ne sort pas du finally.
                try
                {
                    fichier.Fermer();
                }
                catch (InvalidOperationException ex)
                {
                    Console.WriteLine($"fermeture : {ex.Message}");
                }
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"{ex.GetType().Name} : {ex.Message}");
        }
        // fermeture : Fichier deja ferme.
        // IOException : Disque plein.
    }
}

Les filtres when

Un filtre when ajoute une condition au type : si elle est fausse, la clause est ignorée comme si elle n'existait pas, et l'exception continue de monter intacte. C'est plus juste qu'un catch qui teste puis relance, parce que rien n'est attrapé. Un filtre se vérifie sur les cas qu'il ne vise pas : ici, un 503 et une panne réseau sans réponse, dont le StatusCode est nul, doivent traverser.

using System;
using System.Net;
using System.Net.Http;

static class Catalogue
{
    // Tient lieu d'appel HTTP : le statut de la reponse vient du scenario.
    static string Lire(string reference, HttpStatusCode? statut) =>
        throw new HttpRequestException($"Echec pour {reference}.", null, statut);

    static string? Chercher(string reference, HttpStatusCode? statut)
    {
        try
        {
            return Lire(reference, statut);
        }
        // Seul un 404 veut dire « absent ». Le reste n'est pas attrape du tout.
        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }
    }

    public static void Executer()
    {
        Console.WriteLine(Chercher("C-404", HttpStatusCode.NotFound) is null); // True

        // Les cas que le filtre ne vise pas : un 503, et une panne reseau,
        // sans reponse, dont StatusCode est null.
        foreach (var statut in new HttpStatusCode?[] { HttpStatusCode.ServiceUnavailable, null })
        {
            try
            {
                Chercher("C-001", statut);
            }
            catch (HttpRequestException ex)
            {
                Console.WriteLine(ex.StatusCode?.ToString() ?? "pas de reponse");
            }
        }
        // ServiceUnavailable
        // pas de reponse
    }
}

Le runtime traite une exception en deux temps : il cherche d'abord la clause qui la prendra, en évaluant les filtres, puis il déroule la pile et exécute les finally. Un filtre s'exécute donc avant les finally des méthodes plus profondes, la pile encore intacte : un filtre qui journalise et rend false observe l'exception sans l'attraper. Un filtre qui lève est traité comme faux, et son exception est perdue.

using System;

static class DeuxPasses
{
    static void Traiter()
    {
        try
        {
            throw new InvalidOperationException("Commande verrouillee.");
        }
        finally
        {
            Console.WriteLine("finally de Traiter");
        }
    }

    // Rend false : n'attrape rien, mais voit l'exception avant tout deroulement.
    static bool Journaliser(Exception ex)
    {
        Console.WriteLine($"journal : {ex.Message}");
        return false;
    }

    static bool Defectueux(Exception ex) => throw new FormatException("Filtre defectueux.");

    public static void Executer()
    {
        try
        {
            try
            {
                Traiter();
            }
            catch (Exception ex) when (Journaliser(ex))
            {
            }
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"attrapee : {ex.Message}");
        }
        // journal : Commande verrouillee.
        // finally de Traiter
        // attrapee : Commande verrouillee.

        try
        {
            try
            {
                throw new InvalidOperationException("Stock negatif.");
            }
            // Un filtre qui leve compte pour false, et son exception disparait.
            catch (InvalidOperationException ex) when (Defectueux(ex))
            {
                Console.WriteLine("jamais affiche");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"{ex.GetType().Name} : {ex.Message}");
        }
        // InvalidOperationException : Stock negatif.
    }
}

Relancer sans perdre la trace

La trace de pile d'une exception s'écrit quand elle est levée. throw ex; la relève comme si elle naissait là, et la trace repart du catch : tout ce qui était plus profond disparaît, à commencer par la ligne fautive. L'analyseur CA2200 le signale en avertissement dès la compilation.

using System;
using System.Diagnostics;
using System.Linq;

static class Import
{
    static int LireQuantite(string champ) => int.Parse(champ);

    static int ImporterLigne(string ligne)
    {
        try
        {
            return LireQuantite(ligne.Split(';')[1]);
        }
        catch (FormatException ex)
        {
            Console.WriteLine($"ligne rejetee : {ligne}");
            // CA2200, avertissement par defaut : la trace repart d'ici.
            throw ex;
        }
    }

    // Les methodes de cet exemple presentes dans la trace, de la plus profonde a
    // la plus externe (build Debug, sans inlining).
    public static string Trace(Exception ex) =>
        string.Join(" < ", new StackTrace(ex).GetFrames()
            .Select(f => f.GetMethod()!)
            .Where(m => m.DeclaringType == typeof(Import))
            .Select(m => m.Name));

    public static void Executer()
    {
        try
        {
            ImporterLigne("C-002;cinq");
        }
        catch (FormatException ex)
        {
            Console.WriteLine(Trace(ex));
        }
        // ligne rejetee : C-002;cinq
        // ImporterLigne < Executer
        // LireQuantite, la ou l'erreur est nee, a disparu.
    }
}

throw;, sans opérande, relance l'exception en cours avec sa trace. Pour changer de niveau d'explication, on enveloppe : une nouvelle exception, dont le message parle le langage de l'appelant, et l'originale en InnerException. Pour relancer plus tard, hors du catch — après une boucle, sur un autre thread —, ExceptionDispatchInfo.Capture fige l'exception et sa trace, et Throw la relance en ajoutant la nouvelle position au lieu d'effacer l'ancienne ; dans une méthode synchrone, le texte de la trace sépare les deux par la ligne --- End of stack trace from previous location ---, qu'une méthode async n'affiche pas sur .NET 10.

using System;
using System.Diagnostics;
using System.Linq;
using System.Runtime.ExceptionServices;

static class Import
{
    static int LireQuantite(string champ) => int.Parse(champ);

    static int ImporterLigne(string ligne)
    {
        try
        {
            return LireQuantite(ligne.Split(';')[1]);
        }
        catch (FormatException)
        {
            Console.WriteLine($"ligne rejetee : {ligne}");
            throw; // la meme exception, avec sa trace
        }
    }

    // Envelopper : un message au niveau de l'appelant, la cause en InnerException.
    static int ImporterFichier(string[] lignes, int index)
    {
        try
        {
            return ImporterLigne(lignes[index]);
        }
        catch (FormatException ex)
        {
            throw new InvalidOperationException($"Import interrompu a la ligne {index + 1}.", ex);
        }
    }

    static string Trace(Exception ex) =>
        string.Join(" < ", new StackTrace(ex).GetFrames()
            .Select(f => f.GetMethod()!)
            .Where(m => m.DeclaringType == typeof(Import))
            .Select(m => m.Name));

    public static void Executer()
    {
        string[] lignes = ["C-001;5", "C-002;cinq", "C-003;trois"];

        try
        {
            ImporterFichier(lignes, 1);
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine(ex.Message);
            Console.WriteLine(Trace(ex.InnerException!));
        }
        // ligne rejetee : C-002;cinq
        // Import interrompu a la ligne 2.
        // LireQuantite < ImporterLigne < ImporterFichier

        // Relancer hors du catch, plus tard : Capture fige la trace, Throw la
        // prolonge au lieu de la remplacer.
        ExceptionDispatchInfo? premiere = null;
        var rejets = 0;
        foreach (var ligne in lignes)
        {
            try
            {
                LireQuantite(ligne.Split(';')[1]);
            }
            catch (FormatException ex)
            {
                rejets++;
                premiere ??= ExceptionDispatchInfo.Capture(ex);
            }
        }

        Console.WriteLine($"{rejets} lignes rejetees");
        try
        {
            premiere?.Throw();
        }
        catch (FormatException ex)
        {
            Console.WriteLine(Trace(ex));
        }
        // 2 lignes rejetees
        // LireQuantite < Executer < Executer
        // (le premier Executer est l'appel d'origine, le second le Throw)
    }
}

Exceptions différées : async et itérateurs

Le cours Programmation asynchrone montre déjà l'essentiel : une méthode async Task dépose son exception dans la tâche et l'await la relance telle quelle, async void la jette hors de portée de l'appelant, l'await d'un Task.WhenAll ne relance que la première de ses exceptions, et Wait() ou .Result l'enveloppent dans une AggregateException. Une conséquence reste à tirer pour la validation des arguments. Dans une méthode async, même le code placé avant le premier await dépose son exception dans la tâche ; dans un itérateur, rien ne s'exécute avant le premier parcours. L'erreur de l'appelant se manifeste alors loin de l'appel, au moment où quelqu'un attend la tâche ou parcourt la séquence :

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

static class Differe
{
    static async Task<int> CompterAsync(string? reference)
    {
        // Dans une methode async, cette exception part dans la tache rendue.
        ArgumentNullException.ThrowIfNull(reference);
        await Task.Delay(10);
        return reference.Length;
    }

    static IEnumerable<string> Lignes(string? fichier)
    {
        // Dans un iterateur, rien ne s'execute avant le premier MoveNext.
        ArgumentNullException.ThrowIfNull(fichier);
        yield return $"{fichier}:1";
    }

    public static async Task ExecuterAsync()
    {
        var tache = CompterAsync(null);
        var lignes = Lignes(null);
        Console.WriteLine($"appels passes, tache {tache.Status}");
        // appels passes, tache Faulted

        try
        {
            await tache;
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine($"a l'await : {ex.ParamName}");
        }

        try
        {
            foreach (var ligne in lignes)
            {
                Console.WriteLine(ligne);
            }
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine($"au parcours : {ex.ParamName}");
        }
        // a l'await : reference
        // au parcours : fichier
    }
}

La documentation de .NET demande, pour les méthodes qui rendent une tâche, de lever les exceptions d'argument de façon synchrone ; la même raison vaut pour un itérateur, et la découpe est la même : une méthode ordinaire valide, puis délègue à une fonction locale qui porte le async ou le yield.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

static class Differe
{
    // Pas async : la validation s'execute a l'appel, puis la fonction locale
    // porte la partie asynchrone.
    static Task<int> CompterAsync(string? reference)
    {
        ArgumentNullException.ThrowIfNull(reference);
        return CompterCoeurAsync(reference);

        static async Task<int> CompterCoeurAsync(string reference)
        {
            await Task.Delay(10);
            return reference.Length;
        }
    }

    // Meme decoupe pour un iterateur.
    static IEnumerable<string> Lignes(string? fichier)
    {
        ArgumentNullException.ThrowIfNull(fichier);
        return Parcourir(fichier);

        static IEnumerable<string> Parcourir(string fichier)
        {
            yield return $"{fichier}:1";
        }
    }

    public static async Task ExecuterAsync()
    {
        try
        {
            _ = CompterAsync(null);
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine($"a l'appel : {ex.ParamName}");
        }

        try
        {
            _ = Lignes(null);
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine($"a l'appel : {ex.ParamName}");
        }
        // a l'appel : reference
        // a l'appel : fichier

        Console.WriteLine(await CompterAsync("C-001")); // 5
    }
}

Exceptions personnalisées

Un type d'exception à soi se justifie quand un appelant doit la distinguer des autres pour réagir, et qu'aucun type standard ne dit la même chose. Il porte alors, en propriétés typées, les données dont l'appelant a besoin : obliger quelqu'un à analyser un message pour en extraire un nombre est une interface ratée. Les conventions sont stables : le nom finit par Exception, la base est Exception, et les trois constructeurs usuels sont fournis : sans paramètre, avec un message, avec un message et une cause. L'attribut [Serializable] et le constructeur de sérialisation ne servent plus : ils visaient le remoting, absent depuis .NET Core, et le constructeur de base correspondant est obsolète depuis .NET 8 (SYSLIB0051).

using System;

// Le suffixe Exception, Exception pour base, les trois constructeurs usuels,
// et ni [Serializable] ni constructeur de serialisation (SYSLIB0051 depuis .NET 8).
public sealed class StockInsuffisantException : Exception
{
    public StockInsuffisantException()
    {
    }

    public StockInsuffisantException(string message) : base(message)
    {
    }

    public StockInsuffisantException(string message, Exception inner) : base(message, inner)
    {
    }

    public StockInsuffisantException(string reference, int demande, int disponible)
        : base($"Stock insuffisant pour {reference} : {demande} demandes, {disponible} disponibles.")
    {
        Reference = reference;
        Demande = demande;
        Disponible = disponible;
    }

    // Ce dont l'appelant a besoin pour reagir, type : rien a extraire du message.
    public string? Reference { get; }

    public int Demande { get; }

    public int Disponible { get; }
}

static class Entrepot
{
    static void Prelever(string reference, int demande, int disponible)
    {
        if (demande > disponible)
        {
            throw new StockInsuffisantException(reference, demande, disponible);
        }
    }

    public static void Executer()
    {
        try
        {
            Prelever("C-001", 8, 5);
        }
        catch (StockInsuffisantException ex)
        {
            Console.WriteLine(ex.Message);
            Console.WriteLine($"proposer {ex.Disponible} au lieu de {ex.Demande}");
        }
        // Stock insuffisant pour C-001 : 8 demandes, 5 disponibles.
        // proposer 5 au lieu de 8
    }
}

Une hiérarchie d'erreurs métier sous une base commune permet à la frontière HTTP de toutes les traduire d'un seul geste : le cours Qualité d'API le fait avec un IExceptionHandler qui les rend en ProblemDetails, et ne laisse aucun message technique partir chez le client.

Ce que coûte une exception

Un try que rien ne traverse coûte peu : l'essentiel se paie au moment où une exception est levée. Ce moment est cher : il faut allouer l'objet, capturer la trace, chercher un gestionnaire dans la pile puis la dérouler. .NET 9 a remplacé le mécanisme de CoreCLR par celui de NativeAOT, hors Windows x86, deux à quatre fois plus rapide selon ses micro-benchmarks, sans changer l'ordre de grandeur. Utilisée comme un if, sur une entrée invalide fréquente, l'exception coûte des microsecondes là où le chemin valide se compte en dizaines de nanosecondes :

using System;
using System.Diagnostics;

static class Mesure
{
    // L'exception tient lieu de if : chaque champ invalide la leve.
    static int? LireQuantite(string champ)
    {
        try
        {
            return int.Parse(champ);
        }
        catch (FormatException)
        {
            return null;
        }
    }

    static double NanosecondesParAppel(string champ, int appels)
    {
        var chrono = Stopwatch.StartNew();
        for (var i = 0; i < appels; i++)
        {
            LireQuantite(champ);
        }

        return chrono.Elapsed.TotalNanoseconds / appels;
    }

    public static void Executer()
    {
        // Echauffement : le JIT compile et optimise avant la mesure.
        NanosecondesParAppel("42", 100_000);
        NanosecondesParAppel("x", 10_000);

        Console.WriteLine($"valide   : {NanosecondesParAppel("42", 1_000_000):F0} ns");
        Console.WriteLine($"invalide : {NanosecondesParAppel("x", 100_000):F0} ns");
        // dotnet run -c Release, .NET 10.0.8, Windows x64, quatre executions ;
        // les chiffres varient d'une machine et d'une execution a l'autre :
        // valide   : de 42 a 55 ns
        // invalide : de 5140 a 7459 ns
    }
}

La bibliothèque standard offre pour ces cas le patron Try-Parse — int.TryParse, Dictionary.TryGetValue — qui rend un booléen et la valeur en paramètre out, et le patron Tester-Doer, qui fait vérifier d'abord (ContainsKey, CanRead). Ce dernier suppose que rien ne change entre la vérification et l'action : si un autre thread ou un autre processus peut intervenir entre les deux, seule une opération qui vérifie et agit d'un seul geste, comme ConcurrentDictionary.TryRemove, reste juste, et l'exception reste possible pour une ressource externe, un fichier supprimé entre-temps par exemple.

using System;
using System.Diagnostics;

static class Mesure
{
    // Try-Parse : l'echec prevu est une valeur de retour, pas une exception.
    static int? LireQuantite(string champ) => int.TryParse(champ, out var quantite) ? quantite : null;

    static double NanosecondesParAppel(string champ, int appels)
    {
        var chrono = Stopwatch.StartNew();
        for (var i = 0; i < appels; i++)
        {
            LireQuantite(champ);
        }

        return chrono.Elapsed.TotalNanoseconds / appels;
    }

    public static void Executer()
    {
        NanosecondesParAppel("42", 100_000);
        NanosecondesParAppel("x", 100_000);

        Console.WriteLine($"valide   : {NanosecondesParAppel("42", 1_000_000):F0} ns");
        Console.WriteLine($"invalide : {NanosecondesParAppel("x", 1_000_000):F0} ns");
        // Meme machine, meme commande, quatre executions :
        // valide   : de 19 a 24 ns
        // invalide : de 15 a 19 ns
    }
}

Le coût ne justifie pas pour autant de bannir les exceptions : une erreur rare, une panne d'infrastructure ou un bogue coûtent quelques microsecondes une fois, et la trace qu'ils laissent vaut bien davantage.

Erreurs attendues : le patron Result

Une exception dit « ceci n'aurait pas dû arriver ici » et laisse un niveau supérieur s'en occuper. Certaines erreurs sont au contraire des issues normales du métier : une référence inconnue, un stock insuffisant, une règle de validation. L'appelant immédiat doit les traiter, elles arrivent souvent, et la signature d'une méthode qui lève ne les montre pas. Le patron Result les rend visibles : la méthode rend un type qui porte soit la valeur, soit une erreur décrite, et le type oblige l'appelant à traiter les deux cas. Le type ne vient pas de la bibliothèque standard ; quelques lignes suffisent, et des bibliothèques en proposent de plus complets.

using System;
using System.Collections.Generic;

public sealed record Erreur(string Code, string Message);

// Un succes porte une valeur, un echec une erreur, jamais les deux.
public sealed class Resultat<T>
{
    private readonly T? _valeur;
    private readonly Erreur? _erreur;

    private Resultat(T? valeur, Erreur? erreur) => (_valeur, _erreur) = (valeur, erreur);

    public static Resultat<T> Succes(T valeur) => new(valeur, null);

    public static Resultat<T> Echec(Erreur erreur) => new(default, erreur);

    // Aucune propriete Valeur a lire sans verifier : pour obtenir quoi que ce
    // soit, l'appelant ecrit les deux branches.
    public TSortie Selon<TSortie>(Func<T, TSortie> succes, Func<Erreur, TSortie> echec) =>
        _erreur is null ? succes(_valeur!) : echec(_erreur);
}

public sealed record Reservation(string Reference, int Quantite);

public sealed class Entrepot
{
    private readonly Dictionary<string, int> _disponible = new() { ["C-001"] = 5 };

    public Resultat<Reservation> Reserver(string reference, int quantite)
    {
        // Un appel faux reste une exception : c'est un bogue, pas une issue.
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantite);

        // Une reference inconnue, un stock court : des issues prevues du metier.
        if (!_disponible.TryGetValue(reference, out var disponible))
        {
            return Resultat<Reservation>.Echec(new("reference-inconnue", $"{reference} n'existe pas."));
        }

        if (quantite > disponible)
        {
            return Resultat<Reservation>.Echec(new("stock-insuffisant", $"{disponible} disponibles."));
        }

        _disponible[reference] = disponible - quantite;
        return Resultat<Reservation>.Succes(new(reference, quantite));
    }
}

static class Caisse
{
    public static void Executer()
    {
        var entrepot = new Entrepot();
        foreach (var (reference, quantite) in new[] { ("C-001", 3), ("C-001", 3), ("C-999", 1) })
        {
            var message = entrepot.Reserver(reference, quantite).Selon(
                succes: r => $"reserve : {r.Quantite} x {r.Reference}",
                echec: e => $"refuse ({e.Code}) : {e.Message}");
            Console.WriteLine(message);
        }
        // reserve : 3 x C-001
        // refuse (stock-insuffisant) : 2 disponibles.
        // refuse (reference-inconnue) : C-999 n'existe pas.
    }
}

L'exemple garde les deux mécanismes, chacun à sa place : la quantité nulle reste une ArgumentOutOfRangeException, parce qu'elle trahit un bogue de l'appelant, et une base de données injoignable resterait une exception, que personne ne saurait traiter à ce niveau. Le stock insuffisant, qui levait une exception personnalisée plus haut, devient ici une issue. Aucun des deux choix n'est faux en soi. Le critère est l'appelant : s'il doit traiter le cas sur place, et que le cas est fréquent, le Result lui dit dans la signature ce qu'il doit gérer ; si le cas doit remonter plusieurs couches jusqu'à un gestionnaire commun, l'exception le fait sans que chaque couche ait à relayer l'échec.

Le Result a ses coûts : chaque couche le propage à la main, la composition de plusieurs appels s'alourdit, et un Result qui expose sa valeur sans vérification revient au code de retour qu'on oublie de tester, d'où la méthode Selon qui impose les deux branches. L'abus inverse est d'envelopper toute exception dans un échec, ce qui efface la trace et laisse passer des bogues pour des refus. À la frontière d'une API ASP.NET Core, les deux routes aboutissent au même format : un échec se traduit en TypedResults.Problem au point de terminaison, une exception métier en ProblemDetails par le gestionnaire du cours Qualité d'API. C# 15, qui sortira avec .NET 11 (release candidate depuis le 8 septembre 2026, sortie annoncée le 10 novembre), ajoute des types union qui rendent un tel type plus court à écrire : public union Issue(Reservation, Erreur); laisse Reserver rendre directement une Reservation ou une Erreur, et un switch qui traite les deux cas est exhaustif sans bras par défaut. Ils exigent la cible net11.0 ; le cours Nouveautés, de C# 8 à C# 15, en montre un exemple complet et dit pourquoi net10.0 les refuse.

Ce cours vous a servi ? Offrir un café Signaler une erreur