Gérer les cas particuliers avec OneOf

En C#, j'ai longtemps été frustré par la manière de gérer les cas particuliers. J'ai essayé différentes techniques, mais je n'ai trouvé aucune solution satisfaisante... Jusqu'au jour où j'ai découvert la librairie OneOf.

Petit ruisseau en montagne
Petit ruisseau en montagne

Un peu de contexte

En tant que développeur, nous devons en permanence gérer des cas particuliers dans notre code. C'est-à-dire des cas non-idéal, mais fessant partie des règles métiers.

Par exemple, si un utilisateur doit saisir un nombre entier, il faut gérer le cas particulier où l'utilisateur a saisi autre chose qu'un nombre entier. Pour cela, il faut contrôler la saisie et avertir l'utilisateur de son erreur afin qu'il puisse rectifier.

Exemple

Pour la suite de la démonstration, considérons l'exemple suivant, une méthode qui doit créer un produit. Elle a en paramètre le code et le libellé du produit a créé. Si le code est invalide ou si le code est déjà utilisé par un autre produit, alors le produit ne peut être créé. Comment indiquer que l'opération ne s'est pas correctement déroulée?

Retourner un booléen

La solution la plus simple est que la méthode retourne un booléen pour indiquer si l'opération a réussi ou échoué :

public class ProductsService
{
    public bool CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code) || !CheckCodeIsAvailable(code))
            return false;

        CreateProductImpl(code, label);
        return true;
    }
    ...
}

Seulement en cas d'échec, la seule information renvoyée par la méthode est que le produit n'a pas été créé. Donc il sera uniquement possible d'indiquer à l'utilisateur :

Le produit n'a pas pu être créé, car une erreur est survenue. Veuillez vérifier les données puis réessayer. Si le problème persiste, veuillez contactez le support.

Vous êtes certainement accoutumé à ce genre de message générique, donc il n'est pas nécessaire d'expliquer en quoi c'est frustrant.

Avec les exceptions

La solution la plus instinctive est d'utiliser les exceptions pour gérer ces cas particuliers. Après tout, exception n'est-ce pas synonyme de particulier?

Donc pour chaque erreur, il est possible de lever une exception spécifique :

public class ProductsService : ProductsServiceBase
{
    public class InvalidCodeException : Exception { }
    public class CodeAlreadyUsedException : Exception { }

    public void CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code))
            throw new InvalidCodeException();
        if (!CheckCodeIsAvailable(code))
            throw new CodeAlreadyUsedException();

        CreateProductImpl(code, label);
    }
}

Ainsi, il est possible de traiter chaque type d'exception pour indiquer à l'utilisateur la cause de l'erreur :

try
{
    productsService.CreateProduct(code, label);
    DisplaySuccess("Product created successfully.");
}
catch (ProductsService.InvalidCodeException)
{
    DisplayError("`code` is invalid.");
}
catch (ProductsService.CodeAlreadyUsedException)
{
    DisplayError("`code` is already used.");
}

Mais cette approche est peu recommandée, comme le montre cet article qui synthétise les critiques fait à cette technique :

Exceptions for flow control in C#

Pour ma part, mon principal reproche est que les exceptions ne font pas parti de la signature de la méthode (pour la majorité des langages). De ce fait, les outils d'analyse ne peuvent pas correctement aider le développeur. Notamment en l'avertissant des cas qui ne sont pas correctement traités.

Avec une énumération

Une autre solution est d'utiliser une énumération pour définir les différents cas possibles :

public class ProductsService //: ProductsServiceBase
{
    public enum CreateProductResult
    {
        Success,
        InvalidCode,
        CodeAlreadyUsed,
    }

    public CreateProductResult CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code))
            return CreateProductResult.InvalidCode;
        if (!CheckCodeIsAvailable(code))
            return CreateProductResult.CodeAlreadyUsed;

        CreateProductImpl(code, label);
        return CreateProductResult.Success;
    }

    ...
}

Ainsi, il est possible de traiter chaque cas particulier de manière explicite :

var result = productsService.CreateProduct(code, label);
switch (result)
{
    case ProductsService.CreateProductResult.Success:
        DisplaySuccess("Product created successfully.");
        break;
    case ProductsService.CreateProductResult.InvalidCode:
        DisplayError("`code` is invalid.");
        break;
    case ProductsService.CreateProductResult.CodeAlreadyUsed:
        DisplayError("`code` is already used.");
        break;
    default:
        throw new InvalidOperationException();
}

Ainsi, chaque cas est traité explicitement. De plus, le compilateur peut avertir si un cas n'est pas traité avec la règle d'analyse "Add missing cases to switch statement (IDE0010)".

Cependant, cette technique a aussi des limitations. En outre, il n'est pas possible de retourner des informations complémentaires. Par exemple, il sera plus difficile de retourner l'identifiant du produit créer ou autres informations générées lors de la création.

Avec OneOf

OneOf est une bibliothèque .NET permettant de simuler une opération d'unions discriminées, fonctionnalité absente en C# (Simple encoding of unions in C#).

Ainsi, on peut retourner une instance de OneOf qui peut contenir une valeur de plusieurs types explicitement définis :

public class ProductsService
{
    public struct Success { }
    public struct InvalidCode { }
    public struct CodeAlreadyUsed { }

    public OneOf<Success, InvalidCode, CodeAlreadyUsed>
            CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code))
            return new InvalidCode();
        if (!CheckCodeIsAvailable(code))
            return new CodeAlreadyUsed();

        CreateProductImpl(code, label);
        return new Success();
    }
    ...
}

Ainsi, l'appelant doit explicitement traiter chaque type de résultat possible :

var result = productsService.CreateProduct(code, label);
result.Switch(
    success => DisplaySuccess("Product created successfully."),
    invalidCode => DisplayError("`code` is invalid."),
    codeAlreadyUsed => DisplayError("`code` is already used.")
);

Ainsi, si un cas n'est pas traité, le code ne compile pas. De même, si on ajoute un nouveau cas, alors on aura la garantie par le compilateur que ce nouveau cas est correctement traité par les appelants. Cela facilite les évolutions.

Par contre, je ne trouve pas le code correspondant élégant, mais c'est l'approche la plus satisfaisante à ce jour que je connaisse.

It's like a compile time checked switch statement!