# Discriminated Unions in C# 15: A Turn Is Exactly One Thing

> C# 15 adds native union types. Model a board game turn as a union, get exhaustive matching, replace a hand-rolled Result<T>, and avoid the default-value gotcha.

- URL: https://blog.nimblepros.com/blogs/discriminated-unions-in-csharp-15-a-turn-is-exactly-one-thing/
- Author: [Sarah Dutkiewicz](https://blog.nimblepros.com/authors/sadukie/)
- Published: 2026-10-15
- Updated: 2026-11-06
- Series: [Tiles & Tokens](https://blog.nimblepros.com/series/tiles-and-tokens/)
- Categories: [Software Development](https://blog.nimblepros.com/categories/software-development/), [Domain-Driven Design](https://blog.nimblepros.com/categories/domain-driven-design/)
- Publisher: [NimblePros](https://blog.nimblepros.com/)

---

*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](../rolling-into-dotnet-11-modeling-a-board-game-with-csharp-15/), 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:

```csharp
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](https://github.com/NimblePros/tiles-and-tokens-net-11/tree/post-2) 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:

```csharp
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:

```csharp
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.0` uses C# 15 by default, and `union` just works.
- **Each case converts implicitly.** You don't need to wrap anything:

  ```csharp
  GameAction action = new Pass(bob);
  ```

  That works for assignment, method arguments, and `return` statements.
- **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:

  ```csharp
  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, and `Result<int> ok = 42;` converts implicitly.

## Consuming a Union: Exhaustive by Default

Here's the heart of the turn engine:

```csharp
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:

```text
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:

```csharp
#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:

```shell
dotnet build src/TilesAndTokens.Domain -p:DefineConstants=PROVE_EXHAUSTIVENESS
```

```text
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`:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
// 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:

```shell
dotnet run --project src/TilesAndTokens.Demo -- unions
```

```text
--- 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 implements `System.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)`, every `int` assignment 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:

```csharp
[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:

```csharp
/// <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 as `default`.
- `GameAction?` is plain `Nullable<GameAction>`, the same as any nullable struct. If "there might be no action" is a real state in your domain, say so with `GameAction?` 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.** `PlacementResult` and `TurnOutcome` are 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 `int` and `string`. 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](https://github.com/mcintyre321/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:

```csharp
// 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");
```

```csharp
// 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:

1. Declare a named `union` for each `OneOf<...>` signature you return. A name like `PlacementResult` reads better than repeating `OneOf<Placed, Rejected, Blocked>` everywhere, and you no longer need `OneOfBase` to get one.
2. Replace `.Match(...)` and `.Switch(...)` with switch expressions. You get the full pattern language - property patterns, `when` guards, and so on - instead of positional lambdas.
3. Replace `IsT0` / `AsT0` with `is Placed placed`.
4. Return statements usually don't change, since both convert implicitly from each case.

Two things to watch for:

- **Land on switch expressions, not statements.** `Match` was complete by construction: it takes one lambda per case. A switch *statement* over a union loses that guarantee.
- **`default` behaves differently.** A `default(OneOf<Placed, Rejected>)` claims to be the *first* case holding `null`: `IsT0` is `true`, and `Match` calls the first lambda with `null`. A `default` union matches *no* case, and a switch expression throws `SwitchExpressionException`. If you relied on OneOf's behavior anywhere, those spots need a look.

### From `Ardalis.Result`

[Ardalis.Result](https://github.com/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`, `Rejected` with a rule id, `Blocked` with 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 an `Ardalis.Result` is 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:

1. Turn CS8509 into an error, or exhaustiveness is only a suggestion.
2. Prefer switch expressions. Switch statements aren't checked.
3. Watch for `default`. An empty union matches nothing, and the compiler won't warn you.

To try it yourself:

```shell
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.
