This post is part 2 of a 6-part series on domain modeling with C# 15 and .NET 11, told through a small board game.
In part 1, we looked at three ways to model a turn in Tiles & Tokens, where a player can roll the dice, play a card, or pass. All three had the same flaw: the rule “a turn is exactly one of these” lived in comments and conventions, and the compiler couldn’t see it.
The null-heavy wrapper was the most tempting of the three, so let’s start there:
public sealed class TurnAction
{
public string PlayerId { get; set; } = "";
public DiceRoll? Roll { get; set; }
public Card? Card { get; set; }
public Position? Target { get; set; }
public bool IsPass { get; set; }
}
You can set a roll and a card. You can set nothing. The processor’s if order silently decides what happens either way. What we actually want to say is short: a turn is a roll, a card play, or a pass - exactly one, and nothing else.
C# 15 lets us say exactly that.
The code for this post is at the post-2 tag of the companion repo, in src/TilesAndTokens.Domain. Everything here was verified on the .NET 11 RC1 SDK (11.0.100-rc.1.26425.128).
Declaring a Union
First, each kind of action becomes its own small record. Each one carries only the data that action needs:
public sealed record RollDice(PlayerId Player);
public sealed record PlayCard(PlayerId Player, CardId Card, Position? Target = null);
public sealed record Pass(PlayerId Player);
Notice what’s gone. RollDice has no dice total - the game rolls the dice when it applies the action, so the caller can’t hand it a made-up number. Pass has nothing to pass along except who passed. There’s no IsPass flag, because being a Pass is the flag.
Then one line ties them together:
public union GameAction(RollDice, PlayCard, Pass);
That’s the whole declaration. A GameAction holds exactly one of those three cases. There’s no way to build one that holds two, and the compiler knows the complete list.
A few things worth knowing:
No preview flag needed. On RC1, a project targeting
net11.0uses C# 15 by default, andunionjust works.Each case converts implicitly. You don’t need to wrap anything:
GameAction action = new Pass(bob);That works for assignment, method arguments, and
returnstatements.A union can have a body. Members that every case can answer are a nice fit. In the repo, every action knows whose action it is:
public union GameAction(RollDice, PlayCard, Pass) { public PlayerId Player => this switch { RollDice roll => roll.Player, PlayCard play => play.Player, Pass pass => pass.Player, }; }Generic unions work too.
public union Result<T>(T, Error);compiles, andResult<int> ok = 42;converts implicitly.
Consuming a Union: Exhaustive by Default
Here’s the heart of the turn engine:
public TurnOutcome Apply(GameState state, GameAction action)
{
if (action.Player != state.CurrentPlayer)
{
return new ActionInvalid($"It's {state.CurrentPlayer}'s turn, not {action.Player}'s.");
}
// No discard arm: add a case to GameAction and this stops compiling (CS8509).
return action switch
{
RollDice roll => Roll(state, roll),
PlayCard play => Play(state, play),
Pass pass => PassTurn(state, pass),
};
}
There’s no default, no _ => discard arm, and no cast. Pattern matching works directly on the union, and each arm gets a strongly typed variable for its case.
The compiler also knows what can’t be in there. Try to match a type that isn’t one of the cases, and you get an error instead of a branch that never runs:
error CS8121: An expression of type 'GameAction' cannot be handled by a pattern of type 'string'.
What happens when someone adds a case?
In part 1, someone added TradeAction to the open base class six months later. It compiled, and the processor threw NotSupportedException at runtime.
Let’s try that again with a union. The repo has a build constant that adds a trade action:
#if PROVE_EXHAUSTIVENESS
public sealed record TradeResources(PlayerId Player, PlayerId With);
public union GameAction(RollDice, PlayCard, Pass, TradeResources)
#else
public union GameAction(RollDice, PlayCard, Pass)
#endif
Build with it turned on:
dotnet build src/TilesAndTokens.Domain -p:DefineConstants=PROVE_EXHAUSTIVENESS
GameAction.cs(21,36): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'TilesAndTokens.Domain.TradeResources' is not covered.
TurnEngine.cs(13,23): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'TilesAndTokens.Domain.TradeResources' is not covered.
Both places that switch over GameAction fail: the Player member on the union itself, and the turn engine. The compiler hands you a to-do list.
Heads up: CS8509 is a warning, not an error. It only fails the build because the repo turns on
TreatWarningsAsErrors. If you adopt unions, I’d strongly recommend at least<WarningsAsErrors>CS8509</WarningsAsErrors>, or the safety net is just a line in the build output that’s easy to scroll past.
Only switch expressions are checked
There’s a catch, and it’s the same one enums have always had. Exhaustiveness checking applies to switch expressions, not switch statements. This compiles on RC1 with no warning, even though it doesn’t handle Blocked:
PlacementResult result = new Blocked(new Position(1, 1));
switch (result)
{
case Placed:
Console.WriteLine("placed");
break;
case Rejected:
Console.WriteLine("rejected");
break;
}
The demo app has a real example. It uses a switch statement over TurnOutcome so it can print and return in each branch, and it needs a throw after the switch as a fallback:
switch (engine.Apply(state, action))
{
case ActionApplied applied:
Narrator.Outcome(applied.Summary);
return applied.State;
case ActionInvalid invalid:
Narrator.Problem($"Invalid: {invalid.Reason}");
return state;
}
throw new InvalidOperationException("Empty TurnOutcome.");
When a new case is added to TurnOutcome later in the series, the switch expressions break the build and this switch statement doesn’t. If you want the compiler to keep you honest, prefer switch expressions over unions.
Replacing Result Wrappers
Part 1 also had a hand-rolled Result<T> for tile placement:
public sealed class Result<T>
{
// ...
public bool IsSuccess { get; }
public string? Error { get; }
public T Value => IsSuccess
? _value!
: throw new InvalidOperationException($"Cannot read Value of a failed result: {Error}");
}
It had two problems. Nothing forced callers to check IsSuccess before reading Value. And every failure was just a string, so “that cell is taken” and “that breaks a rule” could only be told apart by comparing messages.
As a union, placement has three outcomes, and each one carries its own data:
public sealed record Placed(Tile Tile, int PointsAwarded);
public sealed record Rejected(string Reason, PlacementRuleId Rule);
/// <summary>The target cell already holds a tile.</summary>
public sealed record Blocked(Position Position);
public union PlacementResult(Placed, Rejected, Blocked);
The validator just returns whichever case applies. Implicit conversion means no Result.Success(...) or Result.Failure(...) wrapping:
public static PlacementResult Validate(Board board, Tile candidate)
{
if (!Board.IsInBounds(candidate.Position))
{
return new Rejected($"{candidate.Position} is off the board.", PlacementRuleId.InBounds);
}
if (board.TileAt(candidate.Position) is not null)
{
return new Blocked(candidate.Position);
}
// ... adjacency and terrain rules ...
return new Placed(candidate, PointsPerTile);
}
And the caller must deal with every outcome:
return PlacementValidator.Validate(state.Board, candidate) switch
{
Placed placed => Applied(/* place the tile, award placed.PointsAwarded */),
Rejected rejected => new ActionInvalid($"{rejected.Rule}: {rejected.Reason}"),
Blocked blocked => new ActionInvalid($"{blocked.Position} is already occupied."),
};
There’s no Value to read on failure, because a Rejected doesn’t have a tile. The question “did you check IsSuccess first?” simply can’t come up. And since Blocked is its own case, telling it apart from a rule violation is a type check, not a string comparison.
The repo uses the same idea in two more places:
// The turn engine's result: the new state, or why the action was illegal.
public union TurnOutcome(ActionApplied, ActionInvalid);
// Replaces part 1's Card, which had a nullable Terrain AND a nullable BonusPoints.
public union Card(TileCard, BonusPointsCard);
That Card change is a small one that I like a lot. The legacy card was “a tile card if Terrain is set, a bonus card if BonusPoints is set, and good luck if both or neither are.” Now it’s one or the other, and the compiler knows which.
See It Run
The unions demo plays five scripted turns with seeded dice and a seeded deck, so the output is the same every time:
dotnet run --project src/TilesAndTokens.Demo -- unions
--- Five turns (seed 2026) ---
alice's hand: Plains tile, Forest tile, +3 bonus
bob's hand: +3 bonus, Plains tile, Forest tile
new RollDice(alice)
-> alice rolled 1+2 (+3 pts).
new PlayCard(bob, plains-3, (7,7))
-> bob placed Plains at (7,7) (+1 pt).
new PlayCard(alice, plains-5, (0,0))
-> alice placed Plains at (0,0) (+1 pt).
new PlayCard(bob, forest-1, (0,0))
!! Invalid: (0,0) is already occupied.
new Pass(bob)
-> bob passed.
new RollDice(alice)
-> alice rolled 6+6 (+12 pts), doubles! Drew a card.
--- Final scores ---
alice: 16 pts, 3 cards in hand
bob: 1 pts, 2 cards in hand
5 turns logged. The invalid play didn't use up bob's turn.
Two things to notice. Bob’s play on an occupied cell came back as Blocked, turned into ActionInvalid, and didn’t use up his turn. And Alice rolled doubles and drew a bonus card - the thing the string-flag version from part 1 couldn’t do, because it only stored the dice total.
Under the Hood
It’s worth knowing what the compiler actually generates, because it explains the one big gotcha. I inspected the RC1 output with reflection:
- A union is a struct. It’s marked with
[System.Runtime.CompilerServices.Union]and implementsSystem.Runtime.CompilerServices.IUnion. - Its layout is one object reference. There’s one constructor per case and a single
object Value { get; }property that holds the case instance. - Reference-type cases cost nothing extra. All the cases in this repo are records, so wrapping one in a union doesn’t allocate. The union is a struct holding a reference to the record you already had.
- Value-type cases are boxed. In a scratch
union Num(int, string), everyintassignment allocated 24 bytes. If your cases are small structs on a hot path, keep that in mind.
Gotcha: default is an empty union
Because a union is a struct, default(GameAction) exists. Its Value is null, and it matches none of the cases.
The compiler doesn’t flag that. A switch that handles all three declared cases compiles without a warning, and then throws at runtime on an empty union. The repo has a test that pins this down:
[Fact]
public void Default_IsAnEmptyUnion_ThatNoCaseMatches()
{
// Unions are structs, so default(GameAction) exists. It holds no case, and the
// compiler doesn't flag a switch that only handles the declared cases.
GameAction empty = default;
empty.Value.ShouldBeNull();
Should.Throw<System.Runtime.CompilerServices.SwitchExpressionException>(() => empty.Player);
}
You can handle it with a null arm, which matches the empty union - but the compiler will never ask you for one.
Where does an empty union sneak in? The one that turned up while building the sample was LINQ. hand.FirstOrDefault(c => c.Id == id) over a list of Card returns default(Card) when nothing matches - an empty union, not null. So a card is null check afterward never fires. That’s why Player has a TryGetCard method instead:
/// <remarks>
/// Not <c>Hand.FirstOrDefault(...)</c>: Card is a struct, so "not found" would be an
/// empty union rather than null.
/// </remarks>
public bool TryGetCard(CardId id, out Card card)
{
foreach (var candidate in Hand)
{
if (candidate.Id == id)
{
card = candidate;
return true;
}
}
card = default;
return false;
}
Nullability
Nullable reference types and unions get along well:
- Converting a possibly-null case value to a union is a warning, because the conversion goes through the generated constructor and nullable analysis applies to it: CS8604: Possible null reference argument for parameter ‘value’ in ‘GameAction.GameAction(RollDice value)’. With
TreatWarningsAsErrors, that’s a build error. - If you suppress it with
null!, you get the same empty union asdefault. GameAction?is plainNullable<GameAction>, the same as any nullable struct. If “there might be no action” is a real state in your domain, say so withGameAction?instead of relying on an empty union.
Where Unions Fit, and Where They Don’t
Unions are a great fit when:
- The cases are a closed set of different shapes. A roll, a card play, and a pass don’t share much beyond a player id.
- You’re modeling return values.
PlacementResultandTurnOutcomeare the textbook case: success carries data, each failure carries its own reason. - You’re modeling messages or commands. “This message is one of these five types” is exactly what a union says.
- The cases already exist as unrelated types - including types you don’t own, or even
intandstring. They don’t need to inherit from anything.
They’re a weaker fit when the cases share behavior. Look at the Player member on GameAction again: it’s a switch over this that pulls the same property out of every case. That’s fine for one member. But Tiles & Tokens also has victory conditions - “most points after N rounds,” “first to connect opposite edges,” “last player standing” - and each one has a name, a description, and its own way to check for a winner. That’s shared behavior with different implementations, which is what a class hierarchy is good at. We just want the compiler to know the hierarchy is complete. That’s part 3.
Migration Notes: Moving Off OneOf and Ardalis.Result
If you’ve been modeling this kind of thing already, you’ve probably used a library to do it.
From OneOf
OneOf is the closest match. A OneOf<T0, T1, T2> is also a struct that holds exactly one value, and its Match method makes you handle every case:
// Before: OneOf
public static OneOf<Placed, Rejected, Blocked> Validate(Tile tile) => /* ... */;
var message = Validate(tile).Match(
placed => $"placed +{placed.PointsAwarded}",
rejected => rejected.Reason,
blocked => "occupied");
// After: a C# 15 union
public union PlacementResult(Placed, Rejected, Blocked);
var message = Validate(tile) switch
{
Placed placed => $"placed +{placed.PointsAwarded}",
Rejected rejected => rejected.Reason,
Blocked => "occupied",
};
The migration is mostly mechanical:
- Declare a named
unionfor eachOneOf<...>signature you return. A name likePlacementResultreads better than repeatingOneOf<Placed, Rejected, Blocked>everywhere, and you no longer needOneOfBaseto get one. - Replace
.Match(...)and.Switch(...)with switch expressions. You get the full pattern language - property patterns,whenguards, and so on - instead of positional lambdas. - Replace
IsT0/AsT0withis Placed placed. - Return statements usually don’t change, since both convert implicitly from each case.
Two things to watch for:
- Land on switch expressions, not statements.
Matchwas complete by construction: it takes one lambda per case. A switch statement over a union loses that guarantee. defaultbehaves differently. Adefault(OneOf<Placed, Rejected>)claims to be the first case holdingnull:IsT0istrue, andMatchcalls the first lambda withnull. Adefaultunion matches no case, and a switch expression throwsSwitchExpressionException. If you relied on OneOf’s behavior anywhere, those spots need a look.
From Ardalis.Result
Ardalis.Result (from our own Steve “Ardalis” Smith) solves a related but different problem. A Result<T> has a Status - Ok, NotFound, Invalid, Forbidden, and so on - plus validation errors, and it maps cleanly to HTTP responses through its ASP.NET Core integration.
A union doesn’t replace that integration. Here’s how I’d think about it:
- Inside the domain, where the outcomes are specific to your model -
Placed,Rejectedwith a rule id,Blockedwith a position - a union says more than a general-purpose status enum. Each failure carries exactly the data the caller needs, and the compiler makes the caller handle each one. - At the application boundary, where you’re translating outcomes into HTTP status codes, a general
Result<T>with a status is still a good fit. Mapping a domain union to anArdalis.Resultis one switch expression, and it’s exhaustive.
In other words, these don’t have to be “either/or.” Unions are a better tool for domain outcomes. They’re not a reason to rip out a result type that’s doing its job at the edge of your app.
Takeaway
C# 15 unions let you write “exactly one of these” as one line of code instead of a comment, a convention, and a code review checklist. In Tiles & Tokens, that turned three fragile turn models and a hand-rolled Result<T> into four small unions: GameAction, PlacementResult, TurnOutcome, and Card. Add a case, and every switch expression that forgot it fails the build.
Keep three things in mind:
- Turn CS8509 into an error, or exhaustiveness is only a suggestion.
- Prefer switch expressions. Switch statements aren’t checked.
- Watch for
default. An empty union matches nothing, and the compiler won’t warn you.
To try it yourself:
git checkout post-2
dotnet test
dotnet run --project src/TilesAndTokens.Demo -- unions
dotnet build src/TilesAndTokens.Domain -p:DefineConstants=PROVE_EXHAUSTIVENESS
Next up in part 3, we’ll take on victory conditions with closed hierarchies: class hierarchies that can share behavior, where the compiler still knows every subtype.

