Een langverwachte en veel verzochte feature in C# is eindelijk beschikbaar: Union Types. Op dit moment nog in preview binnen .NET 11 via Visual Studio Insiders, maar we kunnen er al mee aan de slag. Wat is het nou?
In talen als F#, Rust en TypeScript zijn union types al jaren een basisfeature. Een union type is in de basis omschreven als volgt:
Daarmee is iets als onderstaande mogelijk:
public readonly union Animal(Dog, Cat);
1. Hoe deden we dit tot nu toe?
We ‘faketen’ het d.m.v. marker interfaces, abstract base classes of een bekende library zoals OneOf. Het nadeel hiervan was dat compilertechnisch veel niet afgedwongen werd en daardoor programmeerfouten pas op runtime konden optreden.
Een voorbeeld van voor C# 15:
internal abstract record Animal;
internal record Dog(string Name, bool EatsHomeWork) : Animal;
internal record Cat(string Name, bool MeowsAtNight) : Animal;
internal record Shark(string Name, bool IsMegladon) : Animal;
Dit compileert en werkt in een switch-expressie:
List<Animal> animals =
[
new Dog("Pluto 1", true),
new Cat("Whiskers 2", false),
new Shark("Sharky 3", true)
];
foreach (var animal in animals)
{
var animalString = animal switch
{
Dog dog => dog.ToString(),
Cat cat => cat.ToString(),
_ => ""
};
Console.WriteLine(animalString);
}
Dit is valide code, maar heeft als nadeel dat het afhandelen van de case “Shark” niet hoeft, terwijl het type wel als waarde in onze lijst van animals voor kan komen. Dit lost een C# 15 union o.a. op.
2. Hoe ziet en werkt een union type dan?
De syntax is in de basis erg simpel. Om voort te bouwen op het Animal codevoorbeeld van voor C# 15, dat zou er zo uitzien:
public readonly union Animal(Dog, Cat);
public record Dog(string Name, bool EatsHomeWork);
public record Cat(string Name, bool MeowsAtNight);
public record Shark(string Name, bool IsMegalodon);
En dit is voor een basis union eigenlijk het enige wat nodig is. Hiermee geef je aan welke mogelijke types als waarde in de Animal union mogen voorkomen. Als je het bovenstaande naar compilertaal zou omzetten, krijg je dit:
[Union]
public readonly struct AnimalResult : IUnion
{
public object? Value { get; }
[CompilerGenerated]
public AnimalResult(Dog value)
{
Value = value;
}
[CompilerGenerated]
public AnimalResult(Cat value)
{
Value = value;
}
}
Je krijgt netjes een struct met daarin een nullable object, en zoals je ziet aan het CompilerGenerated attribuut is dat het stukje magie van de compiler. En hiermee is meteen ook het mogelijke nadeel zichtbaar dat sommigen zien: een boxed value in je union. Boxed betekent dat ook al hebben wij de toegestane types in een union aangegeven, er alsnog een value of type Object in zit en wij dus niet “weten” wat de type van de value is.
3. Union types hoeven niets gemeen te hebben
Tot nu toe waren onze case types (Dog, Cat en Shark) allemaal een diersoort. Daardoor lijkt een union misschien niet veel meer dan syntactische suiker bovenop een gedeelde interface of abstracte basisklasse. Maar dat is precies waar unions verder gaan: de case types hoeven helemaal niets met elkaar gemeen te hebben. Geen gedeelde interface, geen gemeenschappelijke basisklasse, zelfs geen types die je zelf in de hand hebt.
Neem een voorbeeld uit Domain-Driven Design. Een repository-operatie kan drie totaal verschillende dingen teruggeven: de entity zelf, een record dat aangeeft dat de entity niet gevonden is, of een DomainValidationException van het type Exception:
public record DomainEntity(Guid Id, string Name);
public record DomainEntityNotFound(Guid Id);
public class DomainValidationException(string Message) : Exception(Message);
public union DomainResult(DomainEntity, DomainEntityNotFound, DomainValidationException);
Dit zijn drie fundamenteel ongerelateerde types: een domein-entity (een record), een “niet gevonden” signaal (een ander record) en een exception (een class die overerft van Exception). Je zou deze drie nooit onder één zinnige interface willen dwingen; een INotFound marker op je entity plakken is juist de rommel die we wilden vermijden. Met een union hoeft dat niet:
public DomainResult GetEntity(Guid id)
{
if (!IsValid(id))
return new DomainValidationException("Ongeldige id");
var entity = _repository.Find(id);
if (entity is null)
return new DomainEntityNotFound(id);
return entity;
}
En aan de aanroepkant dwingt de compiler je om alle drie de uitkomsten af te handelen:
var message = GetEntity(id) switch
{
DomainEntity e => $"Gevonden: {e.Name}",
DomainEntityNotFound nf => $"Entity {nf.Id} bestaat niet",
DomainValidationException ex => $"Ongeldig: {ex.Message}",
};
Vergeet je een geval? Compile-error. Dit is het echte verschil met de oude workarounds: unions leggen vast welke types je moet afhandelen, dus het maakt niet meer uit of je er per ongeluk eentje vergeet, zoals je in het voorbeeld hierboven ziet.
Wat nou als deze manier van unions definiëren niet voldoende is? Daar is ook een oplossing voor, namelijk een “unboxed” union.
4. Custom union types en unboxing
Een union heeft weliswaar een set van toegestane types, maar welke type er nou daadwerkelijk in zit weet je dus niet, en dat noem je boxed. D.m.v. een switch of een stukje if (DomainResult.Value is DomainEntity entity) kan je het type van de waarde in de union achterhalen. Dit is nagenoeg gelijk aan hoe de compiler omgaat met switches. Wil je hier meer controle over? Bijvoorbeeld om die boxing te vermijden, dan kom je uit bij Custom union types.
Zoals eerder getoond slaat een union zijn waarde standaard op als object? (boxing). Tevens is het uitlezen van de union waarde via een switch niet de meest performante oplossing als we een boxed union gebruiken. Bij hot paths in je code kan dit een probleem zijn, en daar zijn Custom union types, zoals Microsoft ze noemt, voor bedoeld. In een eerdere alinea zag je een compiler-generated struct, en bij een custom union type laat je wat compiler-magie achterwege, wat resulteert in efficiëntere code.
Als je het Animal voorbeeld erbij pakt, zou een custom union er als volgt uit kunnen zien:
[Union]
internal struct UnboxedAnimal : IUnion
{
private readonly Dog _dogValue = default!;
private readonly Cat _catValue = default!;
public UnboxedAnimal(Dog value) => _dogValue = value;
public UnboxedAnimal(Cat value) => _catValue = value;
public object? Value => _dogValue is not null ? _dogValue : _catValue;
public readonly bool HasValue => _dogValue is not null || _catValue is not null;
public readonly bool TryGetValue(out Dog value)
{
value = _dogValue;
return _dogValue != null;
}
public readonly bool TryGetValue(out Cat value)
{
value = _catValue;
return _catValue != null;
}
public static UnboxedAnimal Create(Dog value) => new(value);
public static UnboxedAnimal Create(Cat value) => new(value);
}
Bovenstaande union geeft je de voordelen van unions, maar geeft je ook de controle en performance terug die je met de standaard unionsyntax inlevert. Namelijk: je kan aangeven dat er altijd een waarde is, en de TryGetValue zorgt ervoor dat de compiler en je code minder hoeven te “raden” naar de juiste waarde:
if (union.Value is Dog dog)
// Dit kan dus expliciter met onderstaande:
if (union.TryGetValue(out Dog value))
De TryGetValue en HasValue noemen we de union members. Dit samen met de “union creation members” zijn handige, op conventie-gebaseerde methodes om je code of de compiler efficiënter te laten werken, zoals de static Create(Dog value) en Create(Cat value).
De grote vraag die je jezelf moet stellen is: is de standaard syntax voldoende, of is controle nodig? Ik zeg: begin simpel, en breid pas uit als er noodzaak is, want zoals je hierboven ziet komt er veel boilerplate code bij.
5. Conversies: waar unions ophouden
Een detail dat makkelijk over het hoofd wordt gezien: hoe zit het met conversies tussen types en unions? Het gedrag is bewust beperkt, en het is handig om te weten waar de grenzen liggen.
De conversie die je al gebruikt hebt gaat van een case type naar de union. Daarom kon je in het DDD-voorbeeld gewoon return entity; schrijven in plaats van return new DomainResult(entity);:
DomainEntity entity = _repository.Find(id);
DomainResult result = entity; // werkt: DomainEntity is een case type
Maar de andere kant op werkt niet, en unions converteren ook niet naar elkaar. Stel je hebt deze twee:
public union Pet(Dog, Cat);
public union Animal(Dog, Cat, Fish);
Dan zou je misschien verwachten dat een Pet in een Animal past, Pet kan immers alleen maar een Dog of Cat zijn, en die passen allebei in Animal. Toch compileert dit niet:
Pet pet = new Dog("Rex");
Animal animal = pet; // compile-error: geen conversie van Pet naar Animal
Unions zijn dus invariant: een smallere union is geen subtype van een bredere, en er is geen automatische co- of contravariantie tussen unions. Wil je van de een naar de ander, dan pak je eerst de waarde eruit en stop je die opnieuw in de andere union:
Animal animal = pet.Value switch
{
Dog d => (Animal)d,
Cat c => (Animal)c,
_ => throw new InvalidOperationException()
};
6. Zelf uitproberen
De eerste union support kwam weliswaar al in .NET 11 Preview 2 / 3, maar dat was nog in de kinderschoenen en bevatte bijvoorbeeld nog niet het attribuut zoals in .NET 11 Preview 5 en verschillende custom union features.
Om te starten:
- Installeer Visual Studio Insiders
- Installeer de .NET 11 Preview SDK
- Zet je project op
net11.0 - Voeg
<LangVersion>preview</LangVersion>toe aan je projectbestand
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
Je kunt direct aan de slag:
public record Success(string Data);
public record NotFound(string Resource);
public record Failure(string Error);
public union ApiResult(Success, NotFound, Failure);
static string Describe(ApiResult result) => result switch
{
Success(var data) => $"Gelukt: {data}",
NotFound(var resource) => $"{resource} bestaat niet",
Failure(var error) => $"Fout: {error}",
};
IDE-ondersteuning is beschikbaar in de laatste C# DevKit Insiders-build en in Visual Studio Insiders.