Types de syndicats C# : les syndicats discriminés arrivent enfin

· 17 min de lecture

Si vous écrivez du C# depuis un certain temps, il y a de fortes chances que vous vous heurtiez à un mur lorsque vous essayez de modéliser quelque chose qui peut être « une chose parmi plusieurs ». Peut-être aviez-vous besoin d’une méthode pour renvoyer soit une valeur de réussite, soit une erreur. Peut-être étiez-vous en train de créer un système de paiement qui gère les cartes de crédit, les virements bancaires et les portefeuilles numériques, chacun avec des données totalement différentes. Ou peut-être avez-vous simplement regardé F# ou Rust et pensé : « Pourquoi ne puis-je pas avoir cela en C# ?

L’attente est presque terminée. Les syndicats discriminés arrivent en C#.

Il s’agit de l’une des fonctionnalités linguistiques les plus demandées depuis des années, avec des discussions communautaires remontant à 2017 et avant. L’équipe de conception du langage C# a travaillé sur une proposition qui introduit un mot-clé union pour définir des hiérarchies de types fermées avec une correspondance de modèles exhaustive. Dans cet article, je souhaite vous expliquer ce que sont les syndicats discriminés, pourquoi ils sont si importants, comment nous les avons simulés jusqu’à présent et à quoi ressemble réellement la syntaxe proposée – avec de vrais exemples de code pour chacun.

Un petit mot avant de plonger dans le vif du sujet : au moment d’écrire ces lignes, la fonctionnalité des types d’union est encore au stade de la proposition et de l’aperçu. La syntaxe et le comportement que je décris ici sont basés sur les derniers documents de conception accessibles au public et sur les discussions de l’équipe de conception du langage C#. Les choses peuvent changer avant la version finale. Je serai clair sur ce qui est confirmé par rapport à ce qui est encore en discussion.

Que sont les syndicats discriminés ?

À la base, une union discriminée (parfois appelée « union étiquetée » ou « type somme ») est un type qui peut contenir l’une d’un ensemble fixe de valeurs possibles, où chaque variante peut transporter des données différentes. La partie « discriminée » signifie que le runtime sait toujours de quelle variante il s’agit — il y a une balise qui l’identifie.

Pensez-y comme à un enum, mais où chaque membre peut transporter sa propre charge utile de données.

Si vous avez utilisé d’autres langages, vous avez probablement déjà vu ce concept :

F# a des syndicats discriminés depuis le premier jour :

type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float
    | Triangle of base: float * height: float

Rust utilise enum pour la même idée :

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

TypeScript réalise quelque chose de similaire avec les unions balisées :

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

Dans tous ces cas, le compilateur connaît toutes les variantes possibles et peut vous obliger à les gérer toutes. C’est le super pouvoir : vérifier l’exhaustivité. Si vous ajoutez une nouvelle variante, le compilateur vous indique partout où vous avez oublié de la gérer.

C# n’a jamais eu de moyen efficace d’exprimer cela. Jusqu’à maintenant.

Comment nous simulons les syndicats aujourd’hui

Au fil des années, la communauté C# a proposé plusieurs solutions de contournement, chacune comportant ses propres compromis. Permettez-moi de passer en revue les approches les plus courantes.

Enregistrements abstraits avec héritage

La solution de contournement la plus idiomatique en C# moderne consiste à utiliser des enregistrements abstraits avec des types dérivés scellés :

public abstract record Shape
{
    public sealed record Circle(double Radius) : Shape;
    public sealed record Rectangle(double Width, double Height) : Shape;
    public sealed record Triangle(double Base, double Height) : Shape;

    private Shape() { } // Prevent external inheritance
}

Cela fonctionne raisonnablement bien. Vous obtenez l’immuabilité, l’égalité des valeurs et vous pouvez utiliser la correspondance de modèles :

double Area(Shape shape) => shape switch
{
    Shape.Circle c => Math.PI * c.Radius * c.Radius,
    Shape.Rectangle r => r.Width * r.Height,
    Shape.Triangle t => 0.5 * t.Base * t.Height,
    _ => throw new InvalidOperationException("Unknown shape")
};
```Mais il existe des inconvénients importants. Le compilateur ne sait pas que la hiérarchie est fermée, vous avez donc toujours besoin de ce bras de suppression de `_` ou vous recevez un avertissement. Si vous ajoutez une nouvelle variante, le compilateur ne vous indiquera pas tous les endroits où vous avez oublié de la gérer : la suppression l'avale silencieusement. Cela va complètement à lencontre du but recherché.

### La bibliothèque OneOf

Une autre approche populaire est le package NuGet [OneOf](https://github.com/mcintyre321/OneOf) :

```csharp
public OneOf<Success<Order>, NotFound, ValidationError> ProcessOrder(OrderRequest request)
{
    // ...
}

// Usage
var result = ProcessOrder(request);
result.Switch(
    success => Console.WriteLine($"Order {success.Value.Id} processed"),
    notFound => Console.WriteLine("Order not found"),
    error => Console.WriteLine($"Validation failed: {error.Message}")
);

OneOf fournit une vérification d’exhaustivité au moment de la compilation via ses paramètres de type génériques, ce qui est génial. Mais il repose sur la correspondance de position (premier type, deuxième type, etc.), les signatures génériques deviennent rapidement lourdes et ne s’intègre pas à la correspondance de modèles du langage. C’est un hack intelligent, mais ça reste un hack.

Énumération manuelle + modèle de données

Certains développeurs choisissent la voie classique avec une balise enum et un conteneur de données :

public enum PaymentType { CreditCard, BankTransfer, DigitalWallet }

public class Payment
{
    public PaymentType Type { get; init; }
    public CreditCardInfo? CreditCard { get; init; }
    public BankTransferInfo? BankTransfer { get; init; }
    public DigitalWalletInfo? DigitalWallet { get; init; }
}

C’est fragile. Rien ne vous empêche de définir Type sur CreditCard mais de renseigner la propriété BankTransfer. Le compilateur ne peut pas vous aider et vous vous retrouvez avec des erreurs d’exécution et des vérifications nulles partout. Il s’agit de l’approche “stringly-typée” de la modélisation de types, et elle n’est pas évolutive.

Toutes ces approches partagent un problème fondamental : elles combattent le langage au lieu de travailler avec lui. Le compilateur ne peut pas raisonner sur l’ensemble fermé des possibilités, vous perdez donc la propriété la plus précieuse des unions discriminées : la vérification exhaustive.

La proposition C# : le mot clé union

La proposition de l’équipe du langage C# introduit un mot-clé dédié union qui définit un ensemble fermé de membres nommés, chacun portant éventuellement des données. Voici la syntaxe de base telle qu’elle a été proposée :

union Shape
{
    Circle(double Radius),
    Rectangle(double Width, double Height),
    Triangle(double Base, double Height)
}

C’est tout. Propre, concis et immédiatement lisible. Chaque membre au sein de l’union définit une variante distincte avec ses propres données. Le compilateur sait que Shape ne peut être qu’une de ces trois choses.

Sous le capot, le compilateur génère une hiérarchie de types scellée, similaire à ce que vous écririez à la main avec des enregistrements abstraits, mais avec une pleine conscience du compilateur de la nature fermée du type. Cela signifie que le compilateur peut imposer l’exhaustivité dans la correspondance de modèles, ce qui constitue le principal avantage.

### Membres à valeur uniquement

Les membres du syndicat ne sont pas obligés de transporter des données. Vous pouvez mélanger des membres porteurs de données avec des membres de valeur simples :

union Option<T>
{
    Some(T Value),
    None
}

Il s’agit du type classique Option/Maybe que les programmeurs fonctionnels demandent en C# depuis des années. None ne contient aucune donnée : c’est juste une balise.

Syndicats génériques

Comme vous pouvez le voir dans l’exemple Option<T> ci-dessus, les syndicats soutiennent les génériques. Voici un exemple plus complexe :

union Result<T, E>
{
    Ok(T Value),
    Error(E Err)
}

Cela ouvre tout un style de gestion des erreurs qui ne repose pas sur des exceptions pour les cas d’échec attendus – ce qui est une pratique standard dans Rust et les langages fonctionnels depuis des années.

Unions avec méthodes

La proposition permet également aux syndicats d’avoir des méthodes, des propriétés calculées et d’implémenter des interfaces, comme n’importe quel autre type :```csharp union Shape { Circle(double Radius), Rectangle(double Width, double Height), Triangle(double Base, double Height);

public double Area => this switch
{
    Circle(var r) => Math.PI * r * r,
    Rectangle(var w, var h) => w * h,
    Triangle(var b, var h) => 0.5 * b * h
};

public double Perimeter => this switch
{
    Circle(var r) => 2 * Math.PI * r,
    Rectangle(var w, var h) => 2 * (w + h),
    Triangle(var b, var h) => b + h + Math.Sqrt(b * b + h * h)
};

}


Remarquez comment l'expression `switch` à l'intérieur de `Area` et `Perimeter` n'a pas besoin d'un bras par défaut. Le compilateur sait que lunion est exhaustive : il nexiste que trois variantes, et toutes les trois sont gérées. Si vous ajoutez une quatrième variante plus tard, le compilateur signalera chaque `switch` qui ne la gère pas.

## Intégration de correspondance de modèles

La correspondance de modèles évolue en C# depuis la version 7.0, et les types d'union sont conçus pour être des citoyens de premier ordre de ce système.

### Expressions de commutation exhaustives

La fonctionnalité la plus efficace est la vérification exhaustive des commutateurs. Avec les unions, le compilateur **connaît** tous les cas possibles :

```csharp
string Describe(Shape shape) => shape switch
{
    Circle(var r)         => $"A circle with radius {r}",
    Rectangle(var w, var h) => $"A {w}x{h} rectangle",
    Triangle(var b, var h)  => $"A triangle with base {b} and height {h}"
};

Pas de bras de rejet. Non _ => throw new NotImplementedException(). Si vous oubliez un cas, le compilateur émet une erreur et non un avertissement. Il s’agit d’une amélioration fondamentale en matière de sécurité.

Correspondance de modèles imbriqués

Les unions composent naturellement avec des motifs imbriqués :

union Expr
{
    Literal(double Value),
    Add(Expr Left, Expr Right),
    Multiply(Expr Left, Expr Right),
    Negate(Expr Inner)
}

double Evaluate(Expr expr) => expr switch
{
    Literal(var v)          => v,
    Add(var left, var right) => Evaluate(left) + Evaluate(right),
    Multiply(var left, var right) => Evaluate(left) * Evaluate(right),
    Negate(var inner)        => -Evaluate(inner)
};

Ce type de structure de données récursive est extrêmement courant dans les compilateurs, les interprètes, les moteurs de règles et la modélisation mathématique. Aujourd’hui, en C#, vous auriez besoin d’une hiérarchie de classes approfondie et du modèle de visiteur. Avec les syndicats, le code est considérablement plus simple.

Clauses de garde

La correspondance de modèles avec les unions prend en charge les gardes when comme vous vous en doutez :

string Classify(Shape shape) => shape switch
{
    Circle(var r) when r > 100   => "Large circle",
    Circle(var r) when r > 10    => "Medium circle",
    Circle(_)                     => "Small circle",
    Rectangle(var w, var h) when w == h => "Square",
    Rectangle _                   => "Rectangle",
    Triangle _                    => "Triangle"
};

Exemples pratiques

Permettez-moi de vous présenter quelques scénarios concrets dans lesquels les types de syndicats améliorent considérablement le code.

Le modèle de résultat : remplacement des exceptions pour les erreurs attendues

L’un des modèles les plus courants dans le développement d’applications modernes consiste à représenter des opérations qui peuvent réussir ou échouer sans utiliser d’exceptions pour le flux de contrôle. Les exceptions doivent être exceptionnelles – des choses comme des pannes de réseau ou des conditions de mémoire insuffisante. Une erreur de validation ou un résultat « introuvable » est un résultat attendu et non une exception.

union Result<T, E>
{
    Ok(T Value),
    Error(E Err)
}

union OrderError
{
    NotFound(Guid OrderId),
    InsufficientStock(string ProductId, int Requested, int Available),
    PaymentDeclined(string Reason),
    ValidationFailed(IReadOnlyList<string> Errors)
}

Result<Order, OrderError> ProcessOrder(OrderRequest request)
{
    if (!Validate(request, out var errors))
        return new ValidationFailed(errors);

    var product = catalog.Find(request.ProductId);
    if (product is null)
        return new NotFound(request.OrderId);

    if (product.Stock < request.Quantity)
        return new InsufficientStock(request.ProductId, request.Quantity, product.Stock);

    var paymentResult = paymentGateway.Charge(request.Payment);
    if (!paymentResult.Success)
        return new PaymentDeclined(paymentResult.Message);

    var order = CreateOrder(request, product);
    return new Ok(order);
}

L’appelant est alors obligé de gérer tous les résultats possibles :

var result = ProcessOrder(request);

var response = result switch
{
    Ok(var order) => Results.Created($"/orders/{order.Id}", order),
    Error(NotFound(var id)) => Results.NotFound($"Order {id} not found"),
    Error(InsufficientStock(var pid, var req, var avail)) =>
        Results.Conflict($"Product {pid}: requested {req}, only {avail} available"),
    Error(PaymentDeclined(var reason)) =>
        Results.UnprocessableEntity($"Payment declined: {reason}"),
    Error(ValidationFailed(var errors)) =>
        Results.BadRequest(new { Errors = errors })
};

Pas de blocs try-catch, pas de types d’exceptions oubliés, pas de surprises d’exécution. Chaque mode de défaillance est visible dans la signature de type et appliqué par le compilateur. Il s’agit d’une amélioration considérable de la fiabilité de l’API.

Modélisation de domaine : types de paiement

Voici un exemple concret de modélisation de domaine — gérant différentes méthodes de paiement dans un système de commerce électronique :

union PaymentMethod
{
    CreditCard(string CardNumber, string Expiry, string Cvv),
    BankTransfer(string Iban, string Bic),
    DigitalWallet(string Provider, string Token),
    CashOnDelivery
}

decimal CalculateProcessingFee(PaymentMethod method, decimal amount) => method switch
{
    CreditCard _                         => amount * 0.029m + 0.30m,
    BankTransfer _                       => 1.50m,
    DigitalWallet(provider: "PayPal", _) => amount * 0.034m + 0.35m,
    DigitalWallet _                      => amount * 0.025m,
    CashOnDelivery                       => 4.99m
};

string FormatForReceipt(PaymentMethod method) => method switch
{
    CreditCard(var num, _, _)       => $"Credit Card ending in {num[^4..]}",
    BankTransfer(var iban, _)       => $"Bank Transfer ({iban[..4]}****)",
    DigitalWallet(var provider, _)  => $"Digital Wallet ({provider})",
    CashOnDelivery                  => "Cash on Delivery"
};

Comparez cela à l’approche actuelle où vous auriez une interface ou une classe abstraite avec cinq implémentations différentes réparties sur cinq fichiers, éventuellement avec un modèle de visiteur au-dessus. L’approche syndicale maintient la définition des données et les opérations ensemble, lisibles et vérifiées de manière exhaustive.

Machines à états

Les machines à états sont omniprésentes dans les logiciels : traitement des commandes, moteurs de flux de travail, gestion des connexions, état de l’interface utilisateur. Les syndicats les rendent explicites et sûrs :

union ConnectionState
{
    Disconnected,
    Connecting(string Host, int Port, DateTime StartedAt),
    Connected(string Host, int Port, TcpClient Client),
    Reconnecting(string Host, int Port, int Attempt, TimeSpan Delay),
    Failed(string Host, Exception Error)
}

ConnectionState HandleEvent(ConnectionState state, ConnectionEvent evt) => (state, evt) switch
{
    (Disconnected, Connect(var host, var port)) =>
        new Connecting(host, port, DateTime.UtcNow),

    (Connecting(var h, var p, _), ConnectionSucceeded(var client)) =>
        new Connected(h, p, client),

    (Connecting(var h, var p, _), ConnectionFailed(var ex)) =>
        new Reconnecting(h, p, Attempt: 1, Delay: TimeSpan.FromSeconds(1)),

    (Reconnecting(var h, var p, var attempt, _), ConnectionSucceeded(var client)) =>
        new Connected(h, p, client),

    (Reconnecting(var h, var p, var attempt, _), ConnectionFailed(var ex)) when attempt >= 5 =>
        new Failed(h, ex),

    (Reconnecting(var h, var p, var attempt, _), ConnectionFailed(_)) =>
        new Reconnecting(h, p, attempt + 1, TimeSpan.FromSeconds(Math.Pow(2, attempt))),

    (Connected(_, _, var client), Disconnect) =>
        new Disconnected,

    _ => state // Ignore events that don't apply to current state
};

Chaque état contient exactement les données pertinentes pour cet état. Vous ne pouvez pas accéder accidentellement à un TcpClient lorsque vous êtes dans l’état Connecting car il n’existe pas sur cette variante. Le système de types applique les invariants de la machine à états.

Considérations sur la sérialisation et l’interopérabilitéL’une des questions pratiques qui se posent immédiatement avec les types d’unions est la suivante : comment les sérialiser ? Si vous créez des API ou stockez des données, vous avez besoin de la sérialisation JSON pour fonctionner correctement.

L’équipe de conception a discuté de la prise en charge intégrée de System.Text.Json pour les types d’union. L’approche attendue consiste à sérialiser avec une propriété discriminante :

{
  "$type": "Circle",
  "radius": 5.0
}
{
  "$type": "Rectangle",
  "width": 10.0,
  "height": 20.0
}

Ceci est cohérent avec la prise en charge de la sérialisation polymorphe existante introduite dans .NET 7 avec les attributs JsonDerivedType. On s’attend à ce que les syndicats travaillent avec System.Text.Json dès le départ, en utilisant le nom de la variante comme discriminateur de type par défaut.

Pour Entity Framework Core, l’approche probable consiste à stocker les valeurs d’union à l’aide d’une colonne discriminatrice, de la même manière que le mappage d’héritage table par hiérarchie (TPH) fonctionne déjà. L’intégration exacte d’EF Core est encore en cours de conception, mais l’infrastructure permettant de gérer les hiérarchies de types fermées existe déjà.

Il convient de noter que l’interopérabilité avec d’autres langages .NET devrait être fluide, puisque les unions se compileront sous le capot selon des hiérarchies de classes IL standard. Le code F# consommant une union C# le verrait comme une hiérarchie de types standard, et vice versa.

Comment l’essayer

Au moment de la rédaction de cet article, les types d’union sont disponibles en tant que fonctionnalité d’aperçu dans les dernières versions d’aperçu du SDK .NET. Pour expérimenter la syntaxe proposée, vous devrez :

  1. Installez le dernier SDK de prévisualisation .NET
  2. Activez la version linguistique d’aperçu dans votre fichier de projet :
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>preview</LangVersion>
  </PropertyGroup>
</Project>

Gardez à l’esprit que les fonctionnalités d’aperçu sont susceptibles de changer. La syntaxe, le comportement et les diagnostics du compilateur peuvent évoluer considérablement avant la version finale. Ne livrez pas de code de production en s’appuyant sur les fonctionnalités du langage d’aperçu, mais expérimentez-les absolument et fournissez vos commentaires. L’équipe C# surveille activement les discussions sur le référentiel csharplang.

Si vous souhaitez suivre l’avancée de la proposition, les principaux endroits à surveiller sont :

  • Le référentiel dotnet/csharplang pour les discussions sur la conception du langage
  • Le référentiel dotnet/roslyn pour la progression de l’implémentation du compilateur
  • Le blog .NET pour les annonces officielles

Comparaison avec les approches existantes

Permettez-moi de faire une comparaison rapide afin que vous puissiez voir comment les différentes approches se comparent :

FonctionnalitéDossiers abstraitsUnDe<T1,T2>Énumération + DonnéesTypes de syndicats
Vérification de l’exhaustivité❌ Non✅ Oui❌ Non✅ Oui
Correspondance de motifs✅ Oui❌ Limité❌ Manuel✅Autochtone
Fermeture imposée par le compilateur❌ Non✅ Oui❌ Non✅ Oui
Données par variante✅ Oui✅ Oui⚠️Fragile✅ Oui
Lisibilité⚠️ Verbeux⚠️ Positionnel❌ Pauvre✅Excellent
Sérialisation✅ Manuel⚠️ Complexe✅ Manuel✅ Intégré
Passe-partout⚠️ Modéré✅ Faible⚠️ Élevé✅ Minime
Aucune dépendance externe✅ Oui❌ NuGet✅ Oui✅ Oui

Ce que cela signifie pour l’écosystème .NET

L’introduction de syndicats discriminés aura des répercussions sur l’ensemble de l’écosystème .NET. Voici ce que je m’attends à voir :

La conception de la bibliothèque sera améliorée. Les API qui renvoient actuellement null pour indiquer « introuvable » ou lèvent des exceptions en cas d’échec de validation pourront renvoyer des types Result<T, E> à la place. Cela rend les modes de défaillance explicites dans la signature de type : vous pouvez voir ce qui peut mal se passer en examinant la signature de la méthode, et non en lisant la documentation ou le code source.

La modélisation du domaine devient plus expressive. L’écart entre le domaine problématique et la représentation du code se réduit considérablement. Lorsque votre expert en domaine dit « un paiement peut être une carte de crédit, un virement bancaire ou un paiement à la livraison », vous pouvez modéliser cela directement comme une union plutôt que de le traduire dans une hiérarchie d’héritage.

Les idées F# deviennent accessibles aux développeurs C#. De nombreux développeurs C# ont admiré de loin le système de types F#, mais n’ont pas été en mesure d’adopter F# dans leur organisation. Les types Union apportent l’une des fonctionnalités les plus puissantes de F# à C#, ce qui constitue une victoire pour l’ensemble de l’écosystème .NET.

Moins d’erreurs d’exécution. La vérification de l’exhaustivité à elle seule empêchera des catégories entières de bugs. Chaque fois que vous ajoutez une nouvelle variante à une union, le compilateur vous guidera vers chaque endroit de la base de code qui nécessite une mise à jour. Fini les cas switch oubliés, plus de NotImplementedException dans les branches par défaut qui n’apparaissent qu’en production.

Conclusion

Les syndicats discriminés figurent en tête de la liste de souhaits de la communauté C# depuis près d’une décennie, et pour cause. Ils comblent une lacune fondamentale du système de types : la capacité de modéliser des données qui peuvent être « une chose parmi plusieurs » avec la sécurité renforcée par le compilateur.

Le mot-clé union proposé apporte une syntaxe claire et concise qui s’intègre profondément à la correspondance de modèles de C#, fonctionne avec des génériques, prend en charge les méthodes et les interfaces et permet une vérification exhaustive qui détecte les bogues au moment de la compilation plutôt qu’à l’exécution.

Que vous construisiez des modèles de domaine, conceviez des API avec des types d’erreur explicites, implémentiez des machines à états ou essayiez simplement de remplacer des hiérarchies d’héritage délicates par quelque chose de plus naturel, les types d’union vont changer la façon dont vous écrivez C#.

Nous attendons cela depuis longtemps. La syntaxe est élégante, l’intégration avec les fonctionnalités du langage existant est réfléchie et l’impact pratique se fera sentir dans l’ensemble de l’écosystème .NET.

Gardez un œil sur les versions préliminaires, expérimentez la fonctionnalité et faites part de vos commentaires à l’équipe de conception du langage. C’est une de ces fonctionnalités qui, une fois que vous l’aurez utilisée, vous vous demanderez comment vous avez pu vivre sans elle.