.NETcsharpArchitectureAPI Design

C# 15 krijgt unions en closed classes. Waar exhaustive matching je API breekt

Een Java-blog over sealed classes waarschuwt: exhaustive matching over modulegrenzen is een versieprobleem. Wat dat betekent voor union en closed in C# 15.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 19 oktober 2026 · 11 min lezen
C# 15 krijgt unions en closed classes. Waar exhaustive matching je API breekt

Neil Madden schreef in april een blogpost over sealed classes en exhaustive pattern matching in Java. Dit weekend stond die alsnog op de voorpagina van Hacker News, met 161 punten en 113 reacties. Zijn conclusie past in één zin: sealed types zijn het nuttigst binnen een component, en riskant zodra je ze in een publieke API zet.

Voor .NET-ontwikkelaars komt dat goed uit. C# 15 levert twee features die hetzelfde doen als Java's sealed interfaces: het union-keyword en de closed-modifier. Ze zitten in de previews van .NET 11, dat in november GA moet worden. Over een paar maanden staan ze in productiecode. Ik wil ze graag gebruiken, maar wel met Maddens waarschuwing in mijn achterhoofd.

Wat Madden eigenlijk zegt

Zijn voorbeeld is simpel. Een sealed interface met twee records, en een switch die beide gevallen afhandelt zonder default. De compiler weet dat er niets anders kan bestaan, dus de switch is compleet. Fijn: voeg je een derde record toe, dan markeert de compiler elke switch die hem mist.

Het probleem zit in het woord "compiler". Die controle gebeurt alleen als alle code opnieuw gecompileerd wordt. Krijgt de library een nieuw subtype en draait de consumer nog op de oude build, dan wordt er niets gecontroleerd. Dan krijg je in Java een MatchException tijdens runtime. Madden trekt daar de conclusie uit dat een nieuw subtype een breaking change is, en onder SemVer dus een major versie. Hij noemt de PEM-API voor cryptografische objecten, die als preview in Java zit, als voorbeeld van een plek waar sealed interfaces in een publieke API staan en waar breuk volgens hem bijna zeker komt zodra er een nieuw geval bij moet.

In de HN-discussie ging een groot deel van de reacties over iets anders: loopt Java achter op Kotlin, Scala en C#? Een Java-architect (gebruiker pron) legde uit dat Java features soms jaren tegenhoudt tot de waarde de extra complexiteit rechtvaardigt. Het versiepunt kwam ook langs, maar minder dan het verdient. Het is namelijk de vraag waar je in .NET over een paar maanden zelf voor staat.

Wat je vandaag in C# hebt

In C# 14 bestaat geen echte gesloten hiërarchie. Het gangbare trucje is een abstract record met een private constructor en geneste subtypes:

public abstract record CheckInStatus
{
    private CheckInStatus() { }

    public sealed record OnTime : CheckInStatus;
    public sealed record Late(TimeSpan By) : CheckInStatus;
    public sealed record Missed(DateTimeOffset ExpectedAt) : CheckInStatus;
}

static string Describe(CheckInStatus status) => status switch
{
    CheckInStatus.OnTime => "op tijd",
    CheckInStatus.Late(var by) => $"{by.TotalMinutes:F0} min te laat",
    CheckInStatus.Missed(var at) => $"gemist, verwacht om {at:HH:mm}",
    _ => throw new UnreachableException(),
};

Jij weet dat er drie gevallen zijn. De compiler weet dat niet en geeft zonder de laatste arm warning CS8509. Die _-arm is dus ruis die er alleen staat om de compiler stil te houden, en hij heeft een vervelende bijwerking: voeg je een vierde status toe, dan valt die stil in de UnreachableException. Je verliest dan juist de controle waarvoor je het patroon gebruikte.

Wat C# 15 toevoegt

closed: een hiërarchie die de compiler kent

Met closed mag een class alleen binnen de eigen assembly direct worden afgeleid. Een closed class is impliciet abstract. Omdat de set directe subtypes vastligt, mag de discard weg:

public closed record class CheckInStatus;
public sealed record OnTime : CheckInStatus;
public sealed record Late(TimeSpan By) : CheckInStatus;
public sealed record Missed(DateTimeOffset ExpectedAt) : CheckInStatus;

static string Describe(CheckInStatus status) => status switch
{
    OnTime => "op tijd",
    Late(var by) => $"{by.TotalMinutes:F0} min te laat",
    Missed(var at) => $"gemist, verwacht om {at:HH:mm}",
};

Let op sealed op de subtypes. Volgens de documentatie van closed is de beperking niet transitief: een gewone record class Missed mag in een andere assembly weer worden afgeleid. Maak de bladeren dus sealed, of closed als ze zelf weer een familie zijn.

union: een gesloten set zonder gemeenschappelijke basis

Een union combineert bestaande types die niets met elkaar te maken hebben. Dat is handig voor return types van handlers, waar je geen basisklasse wilt verzinnen:

public sealed record Accepted(Guid CheckInId);
public sealed record Rejected(string Reason);
public sealed record RateLimited(TimeSpan RetryAfter);

public union CheckInResult(Accepted, Rejected, RateLimited);

app.MapPost("/ping/{monitorId:guid}", async (Guid monitorId, CheckInService service, CancellationToken ct) =>
{
    CheckInResult result = await service.RecordAsync(monitorId, ct);

    return result switch
    {
        Accepted a => Results.Accepted(value: new { a.CheckInId }),
        Rejected r => Results.BadRequest(new { error = r.Reason }),
        RateLimited => Results.StatusCode(StatusCodes.Status429TooManyRequests),
    };
});

In RecordAsync schrijf je gewoon return new RateLimited(retryAfter);: elk case type heeft een impliciete conversie naar de union. De patronen in de switch kijken naar de Value-property van de union. Daarom werkt Accepted a zonder dat je eerst iets uitpakt.

Twee dingen uit de taalreferentie die je moet weten voordat je dit in een hot path zet. De gegenereerde union is een struct met één object? Value. Value types als case worden dus geboxed. Wil je dat niet, dan schrijf je zelf een type met [Union] en TryGetValue-methodes, en dan gebruikt de compiler die bij het matchen. En omdat het een struct is, heeft hij een default waarvan Value null is. Kan dat in jouw code voorkomen, dan wil de compiler een null-arm zien.

De valkuil is in .NET precies dezelfde

Exhaustiveness is kennis van de compiler op het moment van bouwen. In .NET kan de assembly die tijdens runtime laadt makkelijk nieuwer zijn dan die waartegen je compileerde. Een transitieve NuGet-upgrade is genoeg, of een plugin-map met een nieuwere DLL.

Stel: je gebruikt een monitoring-SDK en die zet in versie 2.4 een vijfde geval Paused in de closed hiërarchie. Jouw service is gebouwd tegen 2.3. Wat gebeurt er?

Bij een switch expression genereert de compiler achter de laatste arm nog steeds een throw. Een Paused die nergens matcht, levert dus een SwitchExpressionException op. Dat is de .NET-variant van Maddens MatchException. Vervelend, maar luid.

Bij een switch statement is het erger:

switch (status)
{
    case OnTime:
        metrics.RecordOnTime();
        break;
    case Late late:
        alerts.Warn(late.By);
        break;
    case Missed missed:
        alerts.Page(missed.ExpectedAt);
        break;
}

Een switch statement heeft in C# geen exhaustiveness-check. Een onbekend geval doet hier gewoon niets. Er komt geen exception en er staat niets in je log. Voor een monitoringtool is dat het slechtst denkbare gedrag, want over de status die je niet kende had je juist iets willen horen.

Mijn eerste regel is daarom simpel. Switch je over een closed type of een union, gebruik dan een switch expression. Dan krijg je bij compileren een warning en tijdens runtime een exception. Een statement geeft je geen van beide.

Waar ik ze wel en niet gebruik

Madden zegt "binnen een component". Ik vertaal dat naar .NET als: binnen alles wat samen gebouwd en samen uitgerold wordt.

Een solution met een API-project, een domeinproject en een worker die in dezelfde pipeline worden gebouwd en in dezelfde release live gaan, telt als één component. Daar gebruik ik closed en union zonder aarzelen. Elke nieuwe case geeft warnings op alle plekken die hem missen, en die plekken worden in dezelfde pull request gerepareerd. Dat is het hele voordeel.

Een NuGet-package dat andere teams of klanten gebruiken, is een ander verhaal. Daar is een nieuwe case een breaking change, en dat moet je ook zo communiceren. De feature-specificatie van closed zegt het zelf onder drawbacks: het toevoegen van een afgeleide class aan een closed class kan breaking zijn, en ook het achteraf closed maken van een bestaande class. Twee opties dan. Je maakt van elke nieuwe case een major versie. Of je stelt het type niet closed beschikbaar en laat consumers zelf een fallback schrijven.

De netwerkgrens: altijd een uitweg

De lastigste grens is de draad. Een webhook of een bericht op een queue komt van een producer die gewoon nieuwer kan zijn dan jij. Die kun je niet dwingen om te hercompileren. Hier gebruik ik een closed hiërarchie met een expliciete case voor "ken ik niet":

public closed record class WebhookEvent;
public sealed record PaymentSucceeded(string PaymentId, decimal Amount) : WebhookEvent;
public sealed record PaymentFailed(string PaymentId, string Reason) : WebhookEvent;
public sealed record UnrecognizedEvent(string Type, JsonElement Raw) : WebhookEvent;

public static WebhookEvent Parse(JsonElement json)
{
    var type = json.GetProperty("type").GetString() ?? "";

    return type switch
    {
        "payment.succeeded" => new PaymentSucceeded(
            json.GetProperty("id").GetString()!,
            json.GetProperty("amount").GetDecimal()),
        "payment.failed" => new PaymentFailed(
            json.GetProperty("id").GetString()!,
            json.GetProperty("reason").GetString()!),
        _ => new UnrecognizedEvent(type, json.Clone()),
    };
}

De discard staat nu op de enige plek waar hij thuishoort: bij het parsen van strings uit de buitenwereld. Daarna is alles binnen je eigen code gesloten. Elke handler moet UnrecognizedEvent expliciet afhandelen. Dat wil ik ook: iemand moet bewust kiezen of zo'n event in een dead-letter-tabel belandt of alleen gelogd wordt. Protobuf bewaart onbekende enum-waarden om dezelfde reden.

Maak een nieuwe case zichtbaar in review

De compiler helpt consumers. Als auteur van een library wil je zelf ook merken dat je net een breaking change maakt. Twee dingen die ik in elk package met gesloten types zet.

Eerst het project zelf. Op de .NET 11-preview-SDK zet je de taalversie op preview, en ik maak CS8509 meteen een error:

<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>
  <LangVersion>preview</LangVersion>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
</PropertyGroup>

Daarna een test die de set cases vastlegt als contract. Hij faalt zodra iemand een case toevoegt, ook als alle switches in de library zelf netjes zijn bijgewerkt:

public class PublicContractTests
{
    [Fact]
    public void CheckInStatus_cases_are_part_of_the_public_contract()
    {
        var cases = typeof(CheckInStatus).Assembly
            .GetTypes()
            .Where(t => t.BaseType == typeof(CheckInStatus))
            .Select(t => t.Name)
            .Order()
            .ToArray();

        Assert.Equal(new[] { "Late", "Missed", "OnTime" }, cases);
    }
}

Dat lijkt op dubbel werk, maar zo werkt het in de praktijk. Wie de test aanpast, ziet in de diff dat hij aan een publiek contract komt. Daar hoort een gesprek over de versie bij. Gebruik je al Microsoft.CodeAnalysis.PublicApiAnalyzers, dan zie je het nieuwe publieke type ook terug in PublicAPI.Unshipped.txt. Dat bestand vertelt alleen niet dat het type een bestaande gesloten set openbreekt. De test vertelt dat wel.

Waarom ik ze toch ga gebruiken

Ik ben blij met union en closed. Ik schrijf al jaren _ => throw new UnreachableException() en elke keer voelt het als liegen tegen de compiler. Voor resultaten van handlers en voor statusmachines binnen één applicatie haal ik met deze features een hele klasse bugs naar compile time. ASP.NET Core doet met Results<Ok<T>, NotFound> al jaren iets vergelijkbaars voor minimal APIs. Nu kan het overal.

Maddens waarschuwing geldt in .NET wel letterlijk. Exhaustiveness bestaat bij de compiler. Tussen jouw build en de DLL die in productie laadt, zit alles wat je niet zelf compileert. Op die plek wil je een UnrecognizedEvent of een major versie.

Veelgestelde vragen over unions en closed classes in C# 15

Kan ik union en closed nu al in productie gebruiken? Ze werken op de .NET 11-preview-SDK met LangVersion preview, maar volgens de documentatie van Microsoft zijn nog niet alle onderdelen uit de specificatie geïmplementeerd en komen die in latere previews. Voor een interne tool kun je ermee experimenteren; voor een package dat anderen gebruiken zou ik wachten op de GA van .NET 11, die voor november gepland staat.

Wat is het verschil tussen een union en een closed hiërarchie? Een closed hiërarchie is een abstracte basisklasse waarvan alle directe subtypes in dezelfde assembly staan, dus de cases delen een basis en eventuele members. Een union combineert bestaande types die niets gemeen hoeven te hebben, zoals int en string of records uit verschillende namespaces, en is onder water een struct met een Value-property.

Vervangt een C# 15-union de OneOf-library? Voor nieuwe code grotendeels wel, omdat je exhaustiveness via de gewone switch expression krijgt en geen Match-methode met lambda's meer nodig hebt. Bestaande OneOf-types hoef je niet in één keer om te zetten: dat is veel werk in je hele codebase en levert pas iets op op plekken waar je de switch-syntax echt mist.

Moet ik bestaande abstract records nu closed maken? Voor interne types binnen één solution is dat een goedkope winst, want je kunt overal de _-arm weghalen. Voor publieke types in een package is het zelf een breaking change, omdat consumers die al van je basisklasse afleiden dan niet meer compileren; doe dat dus alleen in een major versie.

Verder lezen


Conclusie: Gebruik union en closed overal waar je code samen gebouwd en samen uitgerold wordt, en kies bij switches altijd een expression boven een statement. Op elke grens die je niet zelf compileert, zoals een NuGet-package of een webhook, is een nieuwe case een breaking change. Zet daar een expliciete case voor onbekende waarden, of verhoog je major versie.

Bronnen: Neil Madden: Java sealed classes and exhaustive pattern matching · Hacker News-discussie · What's new in C# 15 · Union types in de C#-taalreferentie.

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op