C# 15 Union Types in a Real Domain Model: Dispensed, Rejected or Referred

A pharmacy tries to dispense a prescription. One of three things happens: the medication is supplied, the request is refused, or the pharmacist sends a question back to the prescriber. Three outcomes, each with different data. For twenty years C# has made you model that as something else: a class with a success flag and a handful of nullable properties, a base class and a visitor, or an exception for the cases that did not fit.

C# 15, released with .NET 11 yesterday, adds union types: a value that is exactly one of a declared set of types, with the compiler checking that code handles all of them. This article puts unions into a small prescribing model, in the way they would be used in a service, and then asks the questions the feature overview does not answer: what a union compiles to, what an uninitialised one does, how it compares, what it costs, what its JSON looks like to a consumer written in another language, and what happens when part of the estate is still on .NET 10. Every answer comes from compiling and running code on the released SDK.

The shape a union replaces

This is the model most codebases have today. It is in the sample so the two can be tested side by side.

namespace Prescribing.Legacy;

/// <summary>
/// The shape this model replaces: one class, a flag, and a nullable field per possibility. Nothing stops a
/// caller reading Pharmacy on a failure, or the code constructing a success that also carries a rejection.
/// </summary>
public sealed class DispenseResult
{
    public bool Success { get; init; }
    public string? Pharmacy { get; init; }
    public DateTimeOffset? At { get; init; }
    public string? RejectionCode { get; init; }
    public string? RejectionReason { get; init; }
    public string? QuestionForPrescriber { get; init; }
}

public static class LegacyDispensing
{
    public static DispenseResult Decide(Prescription prescription, string pharmacy, DateTimeOffset now)
    {
        if (prescription.Status != PrescriptionStatus.Active)
        {
            // A business outcome reported as an exception: every caller needs a try/catch to find out.
            throw new InvalidOperationException($"Prescription {prescription.Id} is {prescription.Status}.");
        }

        if (prescription.AllergyConflict)
        {
            return new DispenseResult { Success = false, RejectionCode = "allergy", RejectionReason = "Allergy conflict." };
        }

        if (prescription.InteractionWith is not null)
        {
            // Is this a success? The flag has two values and there are three outcomes.
            return new DispenseResult { Success = false, QuestionForPrescriber = "Interaction. Confirm or change." };
        }

        return new DispenseResult { Success = true, Pharmacy = pharmacy, At = now };
    }
}

Nothing in that class is wrong line by line. The defect is structural. One flag and five nullable properties allow 64 combinations of set and unset, of which three are meaningful, and the type system treats all 64 alike. A referral and a rejection are both Success = false; the sample’s tests confirm a caller cannot tell them apart from the flag. A cancelled prescription is not a result at all but an exception, so every caller needs a try block to learn about an ordinary business outcome. And a reader of result.Pharmacy gets a null-reference warning at best.

Left: a legacy result class with a success flag and five nullable fields, 64 combinations of which three are meaningful. Right: a union of three cases, Dispensed, Rejected and ReferredToPrescriber, each with only its own data.
The same decision modelled as a class with a flag and nullable fields, and as a union of three cases.

The union

using System.Text.Json.Serialization;

namespace Prescribing;

public enum PrescriptionStatus { Active, Cancelled, Expired }

/// <summary>A synthetic prescription, reduced to what the dispensing decision needs.</summary>
public sealed record Prescription(
    string Id, string Medication, PrescriptionStatus Status, bool AllergyConflict, string? InteractionWith);

public sealed record Dispensed(string PrescriptionId, string Pharmacy, DateTimeOffset At);

public sealed record Rejected(string PrescriptionId, string Code, string Reason);

public sealed record ReferredToPrescriber(string PrescriptionId, string Question);

/// <summary>
/// What happened when a pharmacy tried to dispense. Exactly one of three things, each with its own data.
/// The classifier lets System.Text.Json tell the cases apart by their property names when reading.
/// </summary>
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union DispenseOutcome(Dispensed, Rejected, ReferredToPrescriber);

The cases are ordinary records. They existed before the union and know nothing about it, which is the difference between a union and a class hierarchy: a union is declared over types, it is not a base type they inherit from. The JsonUnion attribute is about serialisation and is discussed below.

Reflection over the compiled type shows what the declaration becomes: a struct, eight bytes on a 64-bit runtime, with a single object field, a public constructor for each case type, a Value property, the [Union] attribute and the IUnion interface. A union holding a reference type is one pointer wide and allocates nothing of its own.

Decisions without exceptions or nulls

namespace Prescribing;

public static class Dispensing
{
    /// <summary>Every path returns an outcome. Nothing is thrown for a business result and nothing is null.</summary>
    public static DispenseOutcome Decide(Prescription prescription, string pharmacy, DateTimeOffset now)
    {
        if (prescription.Status == PrescriptionStatus.Cancelled)
        {
            return new Rejected(prescription.Id, "cancelled", "The prescriber cancelled this prescription.");
        }

        if (prescription.Status == PrescriptionStatus.Expired)
        {
            return new Rejected(prescription.Id, "expired", "The prescription is past its validity period.");
        }

        if (prescription.AllergyConflict)
        {
            return new Rejected(prescription.Id, "allergy", $"A recorded allergy conflicts with {prescription.Medication}.");
        }

        if (prescription.InteractionWith is { } other)
        {
            return new ReferredToPrescriber(prescription.Id, $"{prescription.Medication} interacts with {other}. Confirm or change.");
        }

        return new Dispensed(prescription.Id, pharmacy, now);
    }
}

Each return relies on the implicit conversion from a case type to the union. The signature now tells the truth: the function returns one of three things, and no caller needs to know which exceptions mean what.

Handlers the compiler keeps honest

namespace Prescribing;

/// <summary>
/// Three consumers of the outcome. Each switch lists every case and has no discard arm, so adding a case
/// to the union breaks the build here until someone decides what the new case means.
/// </summary>
public static class OutcomeHandlers
{
    public static (int Status, string Message) ToHttp(DispenseOutcome outcome) => outcome switch
    {
        Dispensed d => (200, $"Dispensed at {d.Pharmacy}."),
        Rejected r => (422, r.Reason),
        ReferredToPrescriber q => (202, $"Awaiting the prescriber: {q.Question}"),
        null => throw new InvalidOperationException("An outcome with no value reached the API layer."),
    };

    /// <summary>The status a FHIR Task tracking this dispense would move to.</summary>
    public static string ToTaskStatus(DispenseOutcome outcome) => outcome switch
    {
        Dispensed => "completed",
        Rejected => "rejected",
        ReferredToPrescriber => "on-hold",
        null => throw new InvalidOperationException("An outcome with no value cannot be mapped to a task status."),
    };

    /// <summary>An audit line. ToString on a union prints the union's type name, so the text is built here.</summary>
    public static string ToAuditLine(DispenseOutcome outcome) => outcome switch
    {
        Dispensed d => $"{d.PrescriptionId} dispensed pharmacy={d.Pharmacy} at={d.At:O}",
        Rejected r => $"{r.PrescriptionId} rejected code={r.Code}",
        ReferredToPrescriber q => $"{q.PrescriptionId} referred",
        null => "no outcome",
    };
}

Each switch names every case and has no discard arm. That absence is the point. A _ => arm accepts every case you add in future, and the compiler can no longer tell you a decision is missing.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    A[Prescription and pharmacy] --> B[Dispensing.Decide]
    B --> C{DispenseOutcome}
    C -->|Dispensed| D[HTTP 200, task completed]
    C -->|Rejected| E[HTTP 422, task rejected]
    C -->|ReferredToPrescriber| F[HTTP 202, task on hold]
    C -->|no value| G[Refused at the boundary]
    D --> H[Audit line]
    E --> H
    F --> H
    classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
    classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
    classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
    classDef net fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
    classDef policy fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
    class A,B,C compute
    class D,H store
    class E,G danger
    class F policy

To see what that buys, the sample has a second project that compiles the same handlers against a union with a fourth case, a partial supply where the pharmacy owes the balance:

using System.Text.Json.Serialization;

namespace Prescribing;

public sealed record Dispensed(string PrescriptionId, string Pharmacy, DateTimeOffset At);

public sealed record Rejected(string PrescriptionId, string Code, string Reason);

public sealed record ReferredToPrescriber(string PrescriptionId, string Question);

/// <summary>The new requirement: a pharmacy can supply part of the quantity and owe the rest.</summary>
public sealed record PartiallyDispensed(string PrescriptionId, string Pharmacy, int QuantitySupplied, int QuantityOwed);

[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union DispenseOutcome(Dispensed, Rejected, ReferredToPrescriber, PartiallyDispensed);

The build fails, at all three switches, and each message names the case that is not handled:

OutcomeHandlers.cs(9,91): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Prescribing.PartiallyDispensed' is not covered.
OutcomeHandlers.cs(18,75): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Prescribing.PartiallyDispensed' is not covered.
OutcomeHandlers.cs(27,74): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Prescribing.PartiallyDispensed' is not covered.

A new business outcome has become a list of places that need a decision, produced by the compiler in seconds. With the legacy class the same change is a new nullable property, and the places that should care are found by search, review and incident.

⚠️
CS8509 IS A WARNING

Those lines say “error” because the project sets TreatWarningsAsErrors. By default a switch that misses a union case compiles with a warning and throws at run time when the missing case arrives. If you adopt unions for domain modelling, make CS8509 and CS8655 errors in the same pull request, at least for the projects that hold the model.

What the overview does not tell you

Fifteen small programs in the sample each ask one question of the compiler or the runtime. These are the answers that change how you write code.

Table of observed behaviour: a missing case is warning CS8509, a default union throws SwitchExpressionException, ToString prints the type name, == does not compile, value-type cases box at 24 bytes, JSON has no discriminator and needs a classifier to read, and a union needs two types declared by hand to target .NET 10.
Results of the experiments. Amber rows are rules to adopt; red rows are failures that only appear at run time.

A union can be empty. It is a struct, so default, an array element or an unassigned field is a union whose Value is null. A switch with no null arm throws SwitchExpressionException on it. The compiler warns (CS8655) when it can see the default coming, as it did for a value read from a new array; it cannot see one arriving from a deserialiser or another assembly. The handlers above each carry a null arm for that reason, and the API-facing ones throw: an outcome with no value is a defect, and a dispensing system should stop on it, not report success.

ToString() prints the type name. Logging a union writes Prescribing.DispenseOutcome, not the case inside it. Any log line, audit record or exception message built by string interpolation over a union is silently useless. The sample builds its audit line with a switch for that reason.

Equality works through Equals and not through ==. Two unions holding equal records are equal, hash alike, and collapse to one entry in a HashSet. Writing a == b does not compile (CS0019). A union is never equal to its own case value: outcome.Equals(new Dispensed(...)) is false, because one side is a union and the other is not.

Value-type cases box. The single field is an object. A union over int and decimal allocated 24 bytes every time one was created and matched. A union wrapping an object that already exists allocated nothing. For the uncommon hot path over small value types, the language lets you write the union by hand with TryGetValue methods the compiler will use instead:

using System.Runtime.CompilerServices;
const int N = 1_000_000;
static long Allocated(Action action) { long before = GC.GetAllocatedBytesForCurrentThread(); action(); return GC.GetAllocatedBytesForCurrentThread() - before; }
long sum = 0;
long declared = Allocated(() => { for (int i = 0; i < N; i++) { Amount a = i; sum += a switch { int x => x, decimal d => (long)d, null => 0 }; } });
long custom = Allocated(() => { for (int i = 0; i < N; i++) { AmountNoBox a = i; sum += a switch { int x => x, decimal d => (long)d, null => 0 }; } });
Dose shared = new Dose(5);
long reference = Allocated(() => { for (int i = 0; i < N; i++) { DoseOrNote a = shared; sum += a switch { Dose x => x.Milligrams, Note => 0, null => 0 }; } });
Console.WriteLine($"declared union of int and decimal: {declared / (double)N:F1} bytes per create-and-match");
Console.WriteLine($"hand-written union with TryGetValue: {custom / (double)N:F1} bytes per create-and-match");
Console.WriteLine($"declared union wrapping an existing object: {reference / (double)N:F1} bytes per create-and-match");
Console.WriteLine($"sizes: Amount={Unsafe.SizeOf<Amount>()} AmountNoBox={Unsafe.SizeOf<AmountNoBox>()} bytes");
GC.KeepAlive(sum);

public union Amount(int, decimal);
public sealed record Dose(int Milligrams);
public sealed record Note(string Text);
public union DoseOrNote(Dose, Note);

[Union]
public readonly struct AmountNoBox : IUnion
{
    private readonly decimal _decimal;
    private readonly int _int;
    private readonly byte _tag;
    public AmountNoBox(int value) { _int = value; _tag = 1; }
    public AmountNoBox(decimal value) { _decimal = value; _tag = 2; }
    public object? Value => _tag switch { 1 => _int, 2 => _decimal, _ => null };
    public bool HasValue => _tag != 0;
    public bool TryGetValue(out int value) { value = _int; return _tag == 1; }
    public bool TryGetValue(out decimal value) { value = _decimal; return _tag == 2; }
}

That hand-written version allocated nothing, and its struct is 24 bytes where the declared one is 8. For a domain model built from records, none of this matters: use the one-line declaration.

A generic union can be ambiguous. union Validated<T>(T, Failure) works for Validated<int>, including matching on T in a generic method. Validated<Failure> fails to compile at the conversion (CS0457), because both cases are the same type.

Crossing a boundary: unions as JSON

Inside one process a union is a compile-time idea. The moment an outcome is returned from an API, written to a queue or stored, it becomes a contract, and the contract is where the design decisions are.

System.Text.Json in .NET 11 writes a union as the bare shape of its active case. There is no wrapper object and no discriminator property:

{"prescriptionId":"rx-001","pharmacy":"Main Street Pharmacy","at":"2026-11-11T09:30:00+00:00"}
{"prescriptionId":"rx-002","code":"cancelled","reason":"The prescriber cancelled this prescription."}
{"prescriptionId":"rx-005","question":"clarithromycin 500 mg interacts with simvastatin. Confirm or change."}

Writing needs nothing. Reading is where it gets interesting, because the serialiser has to work out which case a JSON object is.

SituationWhat happened
Read a union of object cases with no classifierJsonException: the JSON value type is ambiguous for the union
Read with JsonUnionTypeStructuralClassifierThe case is chosen from the property names. All three cases round-trip
A JSON object whose properties match no caseJsonException: the classifier returned null
A JSON object with only the shared prescriptionIdJsonException, for the same reason
Two cases with identical property namesNotSupportedException: one case can never be selected uniquely
JSON nullA default union, with no value
A union of string and int, reading a numberReads as int
The same union, reading a string under web defaultsJsonException: ambiguous

The last row is probably the web defaults at work: they allow numbers to be read from JSON strings, so a string could be either case. It is a reminder that classification depends on serialiser options as well as on the types.

Structural classification is convenient and it is a fragile public contract. The discriminator is implicit in the set of property names, so adding an optional property to one case, or a new case whose properties overlap an old one, can change how existing messages are read. And every consumer that is not .NET has to implement the same rule by hand. This is the Python side of the sample, reading the JSON Lines file the C# tests export:

"""A non-.NET consumer of the dispense outcomes, reading the JSON Lines file the C# sample writes.

The JSON has no discriminator: each line is the bare shape of one case. A consumer in another language
has to repeat the structural rule the .NET serializer applies, which is what classify() does.

    python consume_outcomes.py outcomes.jsonl
"""
from __future__ import annotations

import collections
import json
import sys


def classify(document: object) -> str:
    """Name the case from the property names alone. Raises ValueError for a shape no case owns."""
    match document:
        case {"prescriptionId": str(), "pharmacy": str(), "at": str(), **rest} if not rest:
            return "dispensed"
        case {"prescriptionId": str(), "code": str(), "reason": str(), **rest} if not rest:
            return "rejected"
        case {"prescriptionId": str(), "question": str(), **rest} if not rest:
            return "referred"
        case _:
            raise ValueError(f"no case has this shape: {sorted(document) if isinstance(document, dict) else type(document).__name__}")


def main() -> int:
    counts: collections.Counter[str] = collections.Counter()
    with open(sys.argv[1], encoding="utf-8") as handle:
        for number, line in enumerate(handle, start=1):
            if not line.strip():
                continue
            try:
                counts[classify(json.loads(line))] += 1
            except ValueError as error:
                print(f"line {number}: {error}")
                return 1
    print(json.dumps(dict(sorted(counts.items()))))
    return 0


if __name__ == "__main__":
    sys.exit(main())

It classified the five exported outcomes correctly, and its tests show the cost of the implicit rule: a fourth case it has never seen is refused, and so is a known case with one extra property. That strictness is a choice. A lenient consumer would mis-file the new case instead, which in a prescribing flow is worse.

For a contract that leaves your team, an explicit discriminator is the safer design. C# 15’s other new modelling feature gives you one. A closed class hierarchy is exhaustive in the same way a union is, and System.Text.Json can infer polymorphism for it:

using System.Text.Json;
JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { InferClosedTypePolymorphism = true };
Outcome outcome = new Rejected("rx-1", "interaction");
string json = JsonSerializer.Serialize(outcome, options);
Console.WriteLine("serialize: " + json);
try { Console.WriteLine("deserialize: " + JsonSerializer.Deserialize<Outcome>(json, options)); }
catch (Exception ex) { Console.WriteLine("deserialize: " + ex.GetType().Name + ": " + ex.Message.Split('\n')[0]); }

public closed record class Outcome(string PrescriptionId);
public sealed record Dispensed(string PrescriptionId, string Pharmacy) : Outcome(PrescriptionId);
public sealed record Rejected(string PrescriptionId, string Reason) : Outcome(PrescriptionId);

That program wrote {"$type":"Rejected","reason":"interaction","prescriptionId":"rx-1"} and read it back as a Rejected. ASP.NET Core 11 describes union-typed bodies with anyOf in the generated OpenAPI document; that was not run for this article.

Unions or closed hierarchies

UnionClosed hierarchy
Relationship to the casesDeclared over existing, unrelated typesCases inherit from the closed base
Shared membersNone; switch to reach anythingDeclared once on the base (PrescriptionId without a switch)
Runtime formA struct holding one reference; value-type cases boxOrdinary class instances
Missing case in a switchWarning CS8509Warning CS8509
Equality and ToStringEquals only; ToString prints the type nameRecord equality and ==; ToString prints the case
JSONBare shape; reading needs a classifier$type discriminator with inferred polymorphism
Use it forResults over types you do not own; combining existing types; in-process flowA domain concept with identity and shared data; anything that is a wire contract

For the dispensing outcome both would work. The cases share a prescription identifier and the outcome is published to other systems, which argues for the closed hierarchy at the service boundary. A union remains the right tool inside the service, for example union Validated<T>(T, Failure) as a return type over types that have no reason to share a base.

Adopting unions in an estate that is not all on .NET 11

  • A union cannot be declared in a net10.0 project as it stands. With the C# 15 compiler and a .NET 10 target the build fails: IUnion and UnionAttribute are not defined. With those two small types declared by hand in the System.Runtime.CompilerServices namespace, the same code compiled and ran on .NET 10.0.15. That is an observation about the compiler, not a statement of what Microsoft supports; check before you rely on it in a shared library.
  • Older-language consumers see a struct. A project pinned to C# 14 that references a C# 15 library can construct the union with new Outcome(new Dispensed(...)) and read .Value. The implicit conversion and patterns on the union itself are refused with CS9327. Such a consumer gets no exhaustiveness checking, so its switch needs a discard arm that throws.
  • Introduce unions at the edges of new code first. Return types of new decision functions are the low-risk place. Changing a widely shared DTO from a class to a union is a breaking change for every consumer and a serialisation change at the same time.
⚖️
CLINICAL SAFETY

The legacy shape can represent a dispensed prescription that also carries a rejection reason, and nothing but convention prevents it. A union removes that class of state from the program, which is a hazard control you can cite. Two conditions apply. An outcome with no value must fail closed at every boundary, as the handlers here do, because “no outcome” must never be rendered as a success. And a consumer that meets a case it does not know must stop and raise it, not fall through to a default. The dispensing rules in this sample are illustrative and are not clinically validated.

Cost

Unions over reference types cost one pointer copy and no allocation, so in a typical service they are free at run time. The costs are elsewhere. Builds break when a case is added, by design, and a team needs to see that as the feature working. A handful of conventions have to be taught and enforced: warnings as errors, no discard arms on domain unions, a null arm at boundaries, no ToString in logs. Wire contracts need a deliberate choice between structural classification and a discriminator. Set against that is the code that goes away: status enums with parallel nullable fields, exception handling for business outcomes, visitor boilerplate, and the defects that live in the gaps between them.

When to use a union

SituationRecommendation
A function with several distinct outcomes, each with its own dataUnion as the return type
Success or a typed failureunion Result(T, Failure) in place of exceptions for expected outcomes
Types you do not own that need to travel togetherUnion; no inheritance required
A domain concept with shared fields, published as a contractClosed hierarchy with a discriminator
A hot path over small value typesMeasure first; a hand-written union with TryGetValue avoids boxing
A simple set of names with no dataAn enum is still the right answer
Truly exceptional failures: I/O, bugs, cancelled operationsExceptions

Key takeaways

  • A union states that a value is exactly one of several types, and the compiler lists every switch that has not handled a newly added case. In the sample, one new case produced three build errors with the missing case named.
  • A missing case is a warning by default. Treat CS8509 and CS8655 as errors where the domain model lives, and do not write discard arms on domain unions.
  • A union is a struct and can be empty. Give boundary code a null arm that fails closed.
  • ToString prints the union’s type name and == does not compile. Build log and audit text with a switch, and compare with Equals.
  • JSON for a union has no discriminator. Inside one team the structural classifier is enough; for a contract other systems read, prefer a closed hierarchy or an explicit discriminator.

If you have moved a real model to unions and hit a case these experiments missed, leave a comment with it.

ℹ️
HOW THIS WAS CHECKED

All code was compiled and run in GitHub Actions on ubuntu-latest with SDK 11.0.100 (run 43279382000): 19 of 19 self-test checks, 7 of 7 Python consumer tests, the expected three build errors from the four-case project, and fifteen experiments whose diagnostics and output are quoted above. The listings are the files in the samples repository. Not run: ASP.NET Core binding and OpenAPI output for unions, source-generated JSON, Native AOT and F# interop. Language rules not shown in an experiment are from Microsoft’s C# 15 documentation.

Glossary

  • Union type— A type whose value is exactly one of a declared set of case types.
  • Case type— One of the types a union can hold.
  • Exhaustive switch— A switch that handles every possible case, so no default arm is needed.
  • Discard arm— The _ => arm of a switch, which matches anything, including cases added later.
  • Closed hierarchy— A class marked closed: only types in the same assembly can derive from it, which lets the compiler check exhaustiveness.
  • Boxing— Wrapping a value type in a heap object so it can be stored as object.
  • Structural classifier— The System.Text.Json component that picks a union case from the property names of a JSON object.
  • Discriminator— A property in serialised data, such as $type, that names the case explicitly.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.