Waarom union types?
Stel: een huisdier in jouw domein is altijd een kat, hond of papegaai — en nooit iets anders.
Hoe modelleer je dat in C#? Er zijn meerdere aanpakken, elk met andere garanties van de compiler, andere leesbaarheid en andere gevolgen voor je EF Core mapping.
In dit artikel loop ik vijf aanpakken langs op hetzelfde voorbeeld: Pet = Cat | Dog | Parrot.
Aanpak 1 — object (de “het is toch gewoon een object”-aanpak)
De meest naive oplossing: stop alles in een List<object> en match op type.
public record Cat(string Name);
public record Dog(string Name);
public record Parrot(string Name);
public static string Describe(object pet) => pet switch
{
Cat c => $"Cat named {c.Name}",
Dog d => $"Dog named {d.Name}",
Parrot p => $"Parrot named {p.Name}",
_ => throw new InvalidOperationException($"Unknown pet type: {pet.GetType().Name}")
};
var pets = new List<object>
{
new Cat("Whiskers"),
new Dog("Rex"),
new Parrot("Polly"),
};
Voordelen
- Nul declaratie-overhead, werkt ook met types die je niet bezit.
Nadelen
pets.Add("not a pet at all")compileert prima — de crash komt pas op runtime.- Geen exhaustiveness-check: vergeet een case in je switch en je compilator zegt niets.
- Geen discoverability: IntelliSense weet niet wat een Pet kan zijn.
Gebruik dit nooit in productie.
Aanpak 2 — enum + god class
Een enum als discriminator, één klasse met alle velden.
public enum PetKind { Cat, Dog, Parrot }
public sealed class Pet
{
public PetKind Kind { get; init; }
public required string Name { get; init; }
public string Describe() => Kind switch
{
PetKind.Cat => $"Cat named {Name}",
PetKind.Dog => $"Dog named {Name}",
PetKind.Parrot => $"Parrot named {Name}",
_ => throw new InvalidOperationException($"Unknown PetKind: {Kind}")
};
}
var pets = new List<Pet>
{
new() { Kind = PetKind.Cat, Name = "Whiskers" },
new() { Kind = PetKind.Dog, Name = "Rex" },
new() { Kind = PetKind.Parrot, Name = "Polly" },
};
Voordelen
- Vertrouwd patroon, geen dependencies, makkelijk te serialiseren.
Nadelen
new Pet { Kind = (PetKind)999, Name = "??" }compileert gewoon.default(PetKind) == Cat(waarde 0) — een legePetis per ongeluk een kat.- Voeg
Hamstertoe aan de enum → alle bestaande switches zijn stil kapot. - Per-case velden (bv.
VocabularySizevoor een papegaai) worden nullable rommel op de god class.
Prima voor simpele, stabiele waardelijsten zonder per-case data.
Aanpak 3 — Abstract base class
Een gedeelde abstracte basis, concrete sealed subtypes per case.
public abstract record Pet
{
public abstract string Name { get; init; }
public abstract string Describe();
public abstract string MakeSound();
}
public sealed record Cat(string Name) : Pet
{
public override string Describe() => $"Cat named {Name}";
public override string MakeSound() => "Miauw";
}
public sealed record Dog(string Name) : Pet
{
public override string Describe() => $"Dog named {Name}";
public override string MakeSound() => "Woof";
}
public sealed record Parrot(string Name) : Pet
{
public override string Describe() => $"Parrot named {Name}";
public override string MakeSound() => "Squawk";
}
var pets = new List<Pet>
{
new Cat("Whiskers"),
new Dog("Rex"),
new Parrot("Polly"),
};
Een nieuwe operatie toevoegen zonder de base aan te raken gaat via C# 14 extension members:
public static class PetSound
{
extension(Pet pet)
{
public string? CallSound() => pet switch
{
Dog d => d.Woof(),
Cat c => c.MakeSound(),
_ => null
};
}
}
Voordelen
- Echte type-safety; IntelliSense toont de hiërarchie.
- Native EF Core support: TPH, TPT, TPC werken out-of-the-box.
- Gedeelde state en invarianten hebben een natuurlijke plek.
Nadelen
- Nieuwe case toevoegen = aanpassing base + elke subclass.
- De set is open: externe assemblies kunnen
Petsubclassen. - Geen compiler-garantie op exhaustiveness (je hebt
_nodig in je switch).
Beste keuze als EF Core directe mapping vereist.
Aanpak 4 — OneOf<T1, T2, T3>
Een generieke struct die precies één van N waarden bevat. In productie gebruik je het OneOf NuGet-pakket; hieronder een minimale handrolled versie om het idee te tonen.
public record Cat(string Name);
public record Dog(string Name);
public record Parrot(string Name);
public readonly struct OneOf<T1, T2, T3>
{
private readonly int _index;
private readonly T1? _v1;
private readonly T2? _v2;
private readonly T3? _v3;
private OneOf(int index, T1? v1, T2? v2, T3? v3)
=> (_index, _v1, _v2, _v3) = (index, v1, v2, v3);
public static implicit operator OneOf<T1, T2, T3>(T1 value) => new(0, value, default, default);
public static implicit operator OneOf<T1, T2, T3>(T2 value) => new(1, default, value, default);
public static implicit operator OneOf<T1, T2, T3>(T3 value) => new(2, default, default, value);
public TResult Match<TResult>(
Func<T1, TResult> f1,
Func<T2, TResult> f2,
Func<T3, TResult> f3) => _index switch
{
0 => f1(_v1!),
1 => f2(_v2!),
2 => f3(_v3!),
_ => throw new InvalidOperationException("Invalid OneOf state.")
};
}
// Gebruik:
var pets = new List<OneOf<Cat, Dog, Parrot>>
{
new Cat("Whiskers"), // implicit conversion
new Dog("Rex"),
new Parrot("Polly"),
};
foreach (var pet in pets)
Console.WriteLine(pet.Match(
c => $"Cat named {c.Name}",
d => $"Dog named {d.Name}",
p => $"Parrot named {p.Name}"));
Voordelen
- Gesloten set: alleen
Cat,DogofParrotpast erin. - Cases zijn volledig onafhankelijk — geen gedeelde base nodig.
.Match()forceert het afhandelen van alle cases (arity-check door de compiler).- Werkt met types die je niet zelf bezit.
Nadelen
- Verbose generics (
OneOf<Cat, Dog, Parrot>) op elke call site. - Volgorde van type-parameters bepaalt de type-identiteit:
OneOf<Dog, Cat, Parrot>is een ander type. - Geen native switch/pattern-matching support — je moet
.Match()gebruiken. - EF Core kent dit type niet; vereist een handgeschreven Value Converter.
Beste keuze voor rijke domeinlogica vóór C# 15, gecombineerd met een aparte persistence-laag.
Aanpak 5 — union keyword (C# 15 / .NET 11+)
Het nieuwe union keyword is een first-class taalprimitief: één regel declaratie, volledig gesloten set, compiler-gegarandeerde exhaustiveness.
// Eén regel — dat is de volledige union-declaratie.
public union Pet(Cat, Dog, Parrot);
public record Cat(string Name) { public string MakeSound() => "Miauw"; }
public record Dog(string Name) { public string MakeSound() => "Woof"; }
public record Parrot(string Name){ public string MakeSound() => "Squawk"; }
// Extensions via C# 14 extension members:
public static class PetDescriber
{
extension(Pet pet)
{
public string Describe() => pet switch
{
Cat c => $"Cat named {c.Name}",
Dog d => $"Dog named {d.Name}",
Parrot p => $"Parrot named {p.Name}",
null => throw new NullReferenceException()
};
public string MakeSound() => pet switch
{
Cat c => c.MakeSound(),
Dog d => d.MakeSound(),
Parrot p => p.MakeSound(),
null => throw new NullReferenceException()
};
}
}
De switch hieronder heeft geen default-arm — en de compiler klaagt niet:
Pet p = new Dog("Buddy");
string result = p switch
{
Cat c => $"{c.Name} says Miauw",
Dog d => $"{d.Name} says Woof",
Parrot p => $"{p.Name} says Squawk"
// Verwijder één arm → compile error. Dat is de garantie.
};
Voordelen
- Eén-regel declaratie, nul boilerplate.
- Volledig gesloten set: externe code kan geen nieuwe cases toevoegen.
- Compiler verifieert exhaustiveness: vergeten case = compile error.
- Cases zijn volledig onafhankelijk van elkaar.
- Full IntelliSense en IDE refactoring-support.
Nadelen
- Vereist C# 15 / .NET 11+ — op het moment van schrijven experimenteel.
- EF Core heeft nog geen native ondersteuning; verwacht pas in een latere versie.
- Geen gedeelde state mogelijk op de union zelf.
Ideaal voor domeinlogica op .NET 11+. Combineer met een aparte persistence-laag voor EF Core.
EF Core: de persistence-realiteit
Wanneer je domeinlogica combineert met EF Core mapping, is de abstracte base de enige aanpak waarbij EF Core de union-semantiek volledig begrijpt:
modelBuilder.Entity<Pet>()
.HasDiscriminator<string>("PetType")
.HasValue<Cat>("cat")
.HasValue<Dog>("dog")
.HasValue<Parrot>("parrot");
TPH, TPT, TPC en OfType<Cat>() in LINQ werken allemaal out-of-the-box.
Voor alle andere aanpakken is de aanbevolen strategie: domein en persistence scheiden.
Domain layer: OneOf<Cat, Dog, Parrot> ← type-safe, closed set, geen EF-afhankelijkheid
Persistence: abstract PetEntity ← EF Core TPH/TPT mapping
Mapper: AutoMapper / handmatig ← vertaling domain ↔ persistence
Totaaloverzicht
| Aanpak | Type-safety | Exhaustiveness | Gesloten set | EF Core | Aanbeveling |
|---|---|---|---|---|---|
1 · object | ❌ | ❌ | ❌ | ❌ | Vermijd in productie |
2 · enum + flat class | 🟡 | ❌ | 🟡 | 🟡 | Alleen voor simpele waardelijsten |
| 3 · Abstract base | ✅ | ❌ | ❌ | ✅ | Beste keuze als EF Core directe mapping vereist |
4 · OneOf<T> | ✅ | 🟡 | ✅ | ❌ | Beste keuze voor domeinlogica vóór C# 15 |
5 · union keyword | ✅ | ✅ | ✅ | 🟡 | Ideaal op .NET 11+ — wacht op native EF Core support |
Keuzewijzer
| Situatie | Aanbeveling |
|---|---|
| Domein + persistence in dezelfde laag, EF Core vereist | Abstract base met TPH/TPT |
| Rijke domeinlogica, persistence in aparte laag | OneOf<T> in domein + abstracte EF entities |
| Nieuw project op .NET 11+, toekomstgericht | union keyword in domein + aparte persistence-laag |
| Simpele configuratiewaarden zonder gedrag | enum |
| Nooit | object |