C# 15 : union types et hiérarchies fermées

Depuis toujours, modéliser en C# une valeur qui est « exactement l'un de ces trois types » impose des contorsions : classe abstraite plus classes scellées, enum plus champs optionnels, ou une dépendance NuGet comme OneOf. C# 15 apporte enfin deux briques natives : le mot-clé union et les hiérarchies closed. Résultat : le compilateur sait quels cas existent, et ton switch devient exhaustif sans clause fourre-tout.

Sommaire

Le problème aujourd'hui

Prends un paiement : il est approuvé, refusé, ou en attente. Chaque cas porte des données différentes. En C# 14 tu écris une classe abstraite avec une classe scellée par cas. Ça marche, mais le compilateur ne peut jamais prouver que ta liste de cas est complète : n'importe quel assembly peut dériver un quatrième cas.

Ce que ça coûte au quotidien
  • Une clause fourre-tout obligatoire dans chaque switch, sinon l'avertissement CS8509
  • Ajouter un cas ne casse aucune compilation : les oublis se découvrent en production
  • Beaucoup de cérémonie pour ce qui est un simple choix entre trois formes
  • Alternative fréquente : une dépendance NuGet (OneOf) qui impose son propre style d'appel

La modélisation classique, avec sa clause fourre-tout défensive.

// The classic modeling: one abstract base, one sealed class per case
public abstract class PaymentResult
{
    public sealed class Approved : PaymentResult
    {
        public required string TransactionId { get; init; }
    }

    public sealed class Declined : PaymentResult
    {
        public required string Reason { get; init; }
    }

    public sealed class Pending : PaymentResult
    {
        public required TimeSpan RetryAfter { get; init; }
    }
}

// Nothing prevents another assembly from adding a fourth case,
// so the compiler cannot prove this switch is complete
static string Describe(PaymentResult result) => result switch
{
    PaymentResult.Approved a => $"Approved {a.TransactionId}",
    PaymentResult.Declined d => $"Declined: {d.Reason}",
    PaymentResult.Pending p => $"Retry in {p.RetryAfter.TotalSeconds}s",
    // Required to silence CS8509, unreachable in practice
    _ => throw new NotSupportedException()
};

Les union types

Le mot-clé union déclare un type dont la valeur est exactement l'un des types listés. La déclaration tient sur une ligne et réutilise des types que tu as déjà définis : records, classes, structs.

Déclarer une union

Les trois records restent des types normaux, utilisables ailleurs. L'union se contente de déclarer le choix fermé entre eux.

// Plain records: nothing special, reusable anywhere
public record Approved(string TransactionId);
public record Declined(string Reason);
public record Pending(TimeSpan RetryAfter);

// A PaymentResult is exactly one of these three types
public union PaymentResult(Approved, Declined, Pending);
Consommer une union

La conversion depuis chaque cas est implicite, et le switch n'a plus besoin de clause fourre-tout : le compilateur connaît la liste complète des cas.

// Conversion from any case type is implicit
PaymentResult result = new Declined("Insufficient funds");

// No catch-all arm: the compiler knows the case list is closed
string message = result switch
{
    Approved a => $"Approved: {a.TransactionId}",
    Declined d => $"Declined: {d.Reason}",
    Pending p => $"Retry in {p.RetryAfter.TotalSeconds}s"
};

// Unions read well as return types
public PaymentResult Charge(decimal amount) =>
    amount <= 0
        ? new Declined("Amount must be positive")
        : new Approved(Guid.NewGuid().ToString("N"));

Les hiérarchies closed

Le modificateur closed s'applique à une classe ou un record de base : seul l'assembly de déclaration peut en dériver. C'est la variante à privilégier quand tes cas partagent des membres communs ou une vraie relation d'héritage, là où l'union convient mieux à des types indépendants.

// Only this assembly may derive from GateState
public closed record class GateState;

public record class Open : GateState;
public record class Shut : GateState;
public record class Moving(double Percent) : GateState;
Switch exhaustif sur une hiérarchie closed

Ajoute un nouvel état dans l'assembly et cette méthode ne compile plus : c'est exactement le filet de sécurité qu'on attend d'une machine à états.

// Exhaustive: adding a derived type breaks the build right here
static string Render(GateState state) => state switch
{
    Open => "open",
    Shut => "shut",
    Moving m => $"moving ({m.Percent:P0})"
};

union et closed sont complémentaires : union assemble des types indépendants, closed verrouille une hiérarchie existante. Les deux donnent la même garantie d'exhaustivité au compilateur.

Pattern matching exhaustif

Une union se combine avec tout ce que le pattern matching sait déjà faire depuis C# 8 : patterns de propriété, patterns de type imbriqués, patterns relationnels. La différence, c'est que l'exhaustivité est désormais vérifiée.

public record Http200(string Body);
public record Http404(string Path);
public record Http500(Exception Error);

public union HttpOutcome(Http200, Http404, Http500);

static string Explain(HttpOutcome outcome) => outcome switch
{
    // Property pattern first, then the general arm for the same case
    Http200 { Body.Length: 0 } => "empty payload",
    Http200 ok => $"{ok.Body.Length} bytes",

    Http404 nf => $"missing: {nf.Path}",

    // Nested type pattern inside a union case
    Http500 { Error: TimeoutException } => "upstream timeout",
    Http500 err => err.Error.Message
};
Règles à retenir
  • Sans clause fourre-tout, le compilateur exige un bras par cas de l'union
  • Un bras plus spécifique (pattern de propriété) ne compte pas comme couvrant tout le cas : garde un bras général
  • L'ordre reste significatif : du plus spécifique au plus général
  • Ajouter un cas à l'union transforme chaque switch incomplet en erreur de compilation

Activer C# 15

C# 15 arrive avec .NET 11. Au moment de l'écriture de ce tip, la version est encore en preview : la GA de .NET 11 est annoncée pour le 10 novembre 2026. Il te faut le SDK .NET 11 et LangVersion en preview.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
État de la fonctionnalité
  • Support compilateur des unions livré en .NET 11 Preview 2
  • Support IDE (complétion, refactorings) arrivé en Preview 3
  • Visual Studio 2026 Insiders ou JetBrains Rider récent recommandé
  • À traiter comme une preview : la syntaxe peut encore bouger avant la GA
Les autres nouveautés C# 15 à connaître
  • Collection expression arguments : passer une capacité ou un comparateur avec la syntaxe with(...)
  • Extension indexers : déclarer un indexeur dans un bloc extension
  • break et continue étiquetés, pour cibler explicitement une boucle englobante
  • Assouplissement des exigences unsafe sur certaines opérations de pointeurs

Cas d'utilisation concrets

Les unions brillent partout où une opération a plusieurs issues légitimes qu'on ne veut pas exprimer par une exception ou un booléen accompagné d'un message.

  • Résultats de domaine : remplacer un Result<T> maison ou un couple (bool Success, string Error) par un choix explicite entre Succès, ValidationÉchouée et NonAutorisé
  • Machines à états : commande Brouillon, Payée, Expédiée, Annulée, avec un switch qui casse à la compilation quand un état est ajouté
  • Parsing et validation : ParseOk, ParseErreurSyntaxe, ParseHorsBornes, chacun portant ses propres données
  • Réponses d'API et couches anticorruption : traduire un code HTTP en cas typés plutôt qu'en tests de codes numériques
  • Handlers de messages : un handler qui retourne Traité, Rejeté ou ÀRejouer, sans exception de flux de contrôle

Avantages et inconvénients face à OneOf

La référence actuelle pour faire des unions en C# est la librairie OneOf. Elle fonctionne dès C# 9 et reste installée dans beaucoup de projets. Voici comment les deux se comparent.

// dotnet add package OneOf
using OneOf;

public OneOf<Approved, Declined, Pending> Charge(decimal amount) =>
    amount <= 0
        ? new Declined("Amount must be positive")
        : new Approved(Guid.NewGuid().ToString("N"));

// Matching goes through a lambda-based Match, not the switch expression
string message = result.Match(
    approved => $"Approved: {approved.TransactionId}",
    declined => $"Declined: {declined.Reason}",
    pending => $"Retry in {pending.RetryAfter.TotalSeconds}s");
Ce que l'union native apporte
  • Aucune dépendance NuGet, aucun type générique à arité fixe (OneOf<T0, T1, T2>...)
  • Le switch classique fonctionne : pas besoin d'apprendre une API Match à base de lambdas
  • Exhaustivité vérifiée par le compilateur, avec messages d'erreur natifs
  • Composable avec le pattern matching existant : patterns de propriété, imbrication, patterns relationnels
  • Meilleure lisibilité des signatures : PaymentResult au lieu de OneOf<Approved, Declined, Pending>
Ce qui plaide encore pour OneOf
  • OneOf tourne aujourd'hui sur .NET 8 LTS ; les unions exigent .NET 11, non LTS, en GA seulement le 10 novembre 2026
  • L'écosystème n'a pas encore rattrapé : sérialisation System.Text.Json, mapping EF Core, générateurs OpenAPI à vérifier au cas par cas
  • OneOf offre des helpers éprouvés (Match, Switch, TryPickT0) que l'union native laisse à ta charge
  • Migrer une base existante implique de toucher toutes les signatures publiques concernées
  • Le tooling IDE des unions est encore jeune : attends-toi à quelques rugosités en preview

En pratique : sur un nouveau service ciblant .NET 11, pars directement sur les unions natives. Sur une base .NET 8 ou .NET 10 en production, garde OneOf et isole les unions derrière tes types de domaine pour rendre la migration mécanique le jour venu.