C# 15 Union Types Compared with the Discriminated-Union Workarounds They Replace

C# 15 Union Types Compared with the Discriminated-Union Workarounds They Replace

pr0h0••28 min read•coding
AI Usage (91%)

This post compares C# 15 union types with the discriminated-union workarounds .NET teams have shipped for years — sealed record hierarchies, OneOf-style generic chains, source-generated unions, visitor interfaces, and (bool, T, string) tuples — by rewriting one three-case order lookup five ways. It also covers what .NET 11's CoreCLR-on-MAUI runtime swap and the SDK and CLI changes mean for your build and CI pipeline.

A .NET major release is one of the few events that forces a team to actually re-read its own code. .NET 11 is that kind of release, but not for the usual reasons: the interesting part is not that C# 15 shipped union types, it is that union types make about five patterns in your codebase obsolete — and that the MAUI runtime swap quietly changes your build pipeline.

Here is my position, up front: adopt unions inside your assemblies aggressively, and stay conservative at the wire boundary until you have personally verified serialization and AOT behavior. Everything below builds toward that rule.

On this page

What .NET 11 and C# 15 Actually Shipped

Confirmed claims versus what the coverage does not say

The source material I had was thin — two news items, both published 2026-11-10: Visual Studio Magazine's release piece (timestamped 20:55 UTC) and Thurrott's (16:27 UTC). Here is the honest split between what those say and what I had to reason about.

ClaimWhere it comes fromStatus
.NET 11 is releasedBoth news items, dated 2026-11-10Confirmed
C# 15 ships union typesBoth headlinesConfirmed
Tooling got fasterBoth headlines ("faster tools")Confirmed as a claim, no methodology published
CoreCLR is the MAUI runtimeHeadlineConfirmed as a claim
Union syntax, exhaustiveness rules, allocation semanticsNot in the coverageUnverified — my inference from the public proposal
Size of the tooling speedupsNot publishedUnknown — treat any number you see as directional
Whether CoreCLR-on-MAUI changes startup time for existing appsNot publishedRequires your own measurement

That table is the most important thing here. The rest of the post is me working from the proposal and from patterns I have shipped, and I keep marking which is which.

⚠️

Every union code sample below is proposal-shaped, not compiler-verified. I did not have a .NET 11 SDK in front of me while writing this. Treat the snippets as the right mental model and the wrong exact syntax until you paste them into a project.

The three headline changes: C# 15 union types, tooling speed, CoreCLR as the MAUI runtime

Union types. C# gains a way to declare that a value is exactly one of a closed set of cases, with pattern matching the compiler can check for exhaustiveness. This is the feature people have been faking with OneOf, hand-rolled visitor interfaces, and nullability-compliant result records for a decade.

Tooling speed. The coverage frames this alongside the language work, which suggests the inner-loop experience — restore, build, hot reload, designer — is the target. No methodology was published with the claim, so the only defensible move is to measure your own solution before and after.

CoreCLR for MAUI. Moving MAUI onto CoreCLR is a bigger change than it reads. It is not a language feature; it is a swap of the execution engine underneath a cross-platform UI stack, with follow-on effects for startup, app size, AOT, and how much reflection you can get away with.

Why unions change code shape and the other two change pipeline shape

These are three different migrations wearing one release note.

Unions touch src/. They change method signatures, switch statements, and the shape of your domain types. The blast radius is your own code, and the failure mode is a compiler diagnostic — loud, local, fixable in an afternoon.

Tooling and CoreCLR touch .github/, global.json, Directory.Build.props, and your publish profile. The blast radius is your pipeline and your production binary. The failure mode is a build that takes eleven minutes instead of six, or an app that starts 300ms slower on a mid-range Android device, or a trimming step that silently removes a type your JSON converter needed.

Union migration fails loudly. Runtime migration fails quietly. Plan your attention accordingly.

The Problem C# Never Solved: A Closed Set of Alternatives

Why Result-style APIs keep leaking into codebases that have no union type

Every service method that can fail in more than one way runs the same internal monologue: I could throw, but throw is for the exceptional; I could return null, but null loses the reason; I could return a bool and an out parameter, but then I need a second out parameter for the error and now I have three out parameters.

So teams invent Result<T>, then Result<T, TError>, then a ValidationResult for the endpoints that need field-level errors, then an OperationResult because someone did not like the first two. None of these are wrong. All of them are the same idea expressed four ways, and none of them tell the compiler that the case list is closed.

That is the actual gap. C# has had pattern matching since C# 7 and switch expressions since C# 8. What it never had was a way to tell the compiler this is all of them.

The four properties a good discriminated union must provide

I judge every workaround against these four. Any pattern that fails two or more is one I try to keep out of new code.

PropertyWhat it meansWhy it matters
ClosednessThe case list is fixed and named, not an open generic or a subclassable baseCallers cannot invent a case you did not plan for
ExhaustivenessThe compiler errors or warns when a switch misses a caseAdding a case surfaces every handler that needs updating
Payload typingEach case carries its own typed dataNo object, no string error blob, no casting
Zero ceremonyDeclaring and using it costs about as much as a classIf it costs more than the bug, nobody uses it

Exhaustiveness is the one that pays for itself. Closedness is what makes exhaustiveness possible. Payload typing is what makes it pleasant. Zero ceremony is what makes it survive code review.

How each workaround fails at least one of the four properties

WorkaroundClosednessExhaustivenessPayload typingCeremony
Sealed record hierarchyYesOnly if you delete defaultYesLow
OneOf<T0, T1>Weak — no named typeNoYesMedium, grows with cases
Source-generated unionsYesYes (generated Match)YesLow, but generator dependency
Visitor patternYesYes, by constructionYesVery high
(bool ok, T value, string error)NoNoNo — one T, one stringLowest

The sealed hierarchy row is the interesting one. It gets three and a half out of four, which is exactly why it became the de facto standard. Its failure is entirely in the default arm — and the default arm is added by humans under deadline pressure.

The Workaround Cast: Five Patterns .NET Teams Actually Ship

Workaround 1 — sealed record hierarchies with switch expressions

This is the pattern I reach for today, and it looks like this:

OrderLookup.cs
public abstract record OrderLookup
{
  public sealed record Found(Order Order) : OrderLookup;
  public sealed record NotFound(string OrderId) : OrderLookup;
  public sealed record ValidationFailed(IReadOnlyList<string> Errors) : OrderLookup;
}

Exhaustiveness works — but only conditionally. A switch expression with no discard arm produces warning CS8509 when a case is missing. A switch expression with _ => produces nothing. The pattern gives you exhaustiveness and then lets you throw it away with one line, and that line looks like defensive programming.

Workaround 2 — OneOf-style generic chains and their allocation profile

OneOf<T0, T1> is a struct with a discriminant index and a field per type. For two cases it is fine. For three, you either nest or use the three-arity overload; for five, the type signature becomes hostile:

public OneOf<OrderLookup.Found, OrderLookup.NotFound, OrderLookup.ValidationFailed> Lookup(string id)

The allocation story is better than people assume: no boxing, no heap allocation for the union itself. The costs are structural. The struct size is the sum of all case payloads, so a union containing a large struct copies that struct on every pass. And because there is no named type, OneOf<A, B> and OneOf<A, B, C> can both exist for the same concept, and nothing stops a codebase from using both.

Workaround 3 — source-generated unions (Dunet and similar)

Dunet takes the sealed-hierarchy idea and generates the boring parts: a Match method with a delegate per case, Switch for void returns, and mapping helpers. You write the case records, the generator writes the exhaustive dispatch. It solves the ceremony problem and preserves closedness.

The cost is a source generator in your build graph. That is usually fine, and occasionally catastrophic when the generator's Roslyn version and your SDK version disagree — which is exactly the kind of breakage a major SDK bump can trigger. Verify it before you bump TFMs.

Workaround 4 — the visitor pattern and double dispatch

The classic. Each case gets an Accept method; each consumer implements an interface with one method per case.

public interface IOrderLookupVisitor<out TResult>
{
    TResult Visit(OrderLookup.Found found);
    TResult Visit(OrderLookup.NotFound notFound);
    TResult Visit(OrderLookup.ValidationFailed failed);
}

It is genuinely exhaustive — the compiler forces every implementer to handle every case. It is also the most expensive pattern on this list to extend: adding a case means one new interface method plus every implementation everywhere, including in assemblies you may not own. Visitor optimizes for adding operations and punishes adding cases. Most business domains do the opposite.

Workaround 5 — (bool ok, T value, string error) tuples and bool/out-var

The pragmatic minimum:

public (bool Ok, Order? Value, string? Error) Lookup(string id)

Zero ceremony, zero allocation beyond the string. It also collapses an arbitrary number of failure reasons into one string, which means every caller that needs to distinguish "not found" from "invalid" ends up parsing the error message or ignoring it. This pattern is correct for genuinely binary outcomes and wrong for everything else.

Rewriting the Same Three-Case Endpoint Five Ways

Defining the domain: an order lookup that returns found, not-found, or validation-failed

Same contract every time: given an order id, return one of three outcomes. Found carries the order. NotFound carries the id we looked for. ValidationFailed carries field errors. The handler maps each to an HTTP response.

The sealed-hierarchy version, with the switch that silently gains a default arm

OrdersEndpoint.cs
public static IResult Handle(OrderLookup result) => result switch
{
  OrderLookup.Found f => Results.Ok(f.Order),
  OrderLookup.NotFound n => Results.NotFound(new { id = n.OrderId }),
  OrderLookup.ValidationFailed v => Results.BadRequest(v.Errors),
  _ => Results.Problem("unhandled order lookup case")   // <-- the problem
};

Now someone adds a fourth case in a different PR:

public sealed record RateLimited(TimeSpan RetryAfter) : OrderLookup;

The project builds. There is no warning, because the discard arm covers RateLimited. The endpoint returns a 500 with the string "unhandled order lookup case" to a customer. That is the failure mode in one paragraph, and I have watched it happen twice.

Remove the _ => arm and the same change produces CS8509 at build time:

warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive).

A warning. Not an error. Unless you configured it.

The OneOf version, including the wrapper type explosion at three or more cases

public OneOf<Order, NotFound, ValidationFailed> Lookup(string id)

Three cases is where it starts to hurt, because the dispatch callback signature has three parameters and reads like a puzzle:

return result.Match<IResult>(
    order => Results.Ok(order),
    notFound => Results.NotFound(new { id = notFound.OrderId }),
    failed => Results.BadRequest(failed.Errors));

It works, it is allocation-free, and the type name appears in every method signature and every test double. The moment your public API exposes OneOf<...>, you have made the library a dependency of your contract.

The visitor version and the boilerplate cost per new case

public sealed class OrderLookupResponseVisitor : IOrderLookupVisitor<IResult>
{
    public IResult Visit(OrderLookup.Found found) => Results.Ok(found.Order);
    public IResult Visit(OrderLookup.NotFound notFound) => Results.NotFound(new { id = notFound.OrderId });
    public IResult Visit(OrderLookup.ValidationFailed failed) => Results.BadRequest(failed.Errors);
}

Three methods for one handler. Adding RateLimited means editing the interface and every implementation. If the interface is public, that is a breaking change.

The C# 15 union version and what the compiler now checks for you

Proposal-shaped, not compiler-verified:

OrderLookup.cs — union shape, unverified syntax
public union OrderLookup(Order, NotFound, ValidationFailed)
{
  Order(Order value);
  NotFound(string OrderId);
  ValidationFailed(IReadOnlyList<string> Errors);
}

The properties that matter are the ones you stop writing. There is one named type. NotFound is not a free-floating class that can be subclassed elsewhere. Pattern matching over the case types is something the compiler understands as a closed set, so a switch expression that misses ValidationFailed is a diagnostic without you having to remember to omit default. And deconstruction out of the union gives you the case type back with its payload intact.

The first thing I would verify, before rewriting anything, is whether the diagnostic is a warning or an error, and whether there is an opt-in to make it an error. If it is a warning, the whole advantage over sealed records narrows considerably — you will need to escalate it in Directory.Build.props either way.

Side-by-side comparison of all six approaches

DimensionSealed recordsOneOfSource-gen unionsVisitorTupleC# 15 union (expected)
Heap alloc per value1 (the record)0 (struct)110 (struct)Depends on class vs struct form
ClosednessYesWeakYesYesNoYes
ExhaustivenessWarning onlyNoneGenerated MatchCompiler-enforcedNoneCompiler-checked
IDE navigationGoodPoor (stacked generics)GoodMediumPoorUnknown until tooling ships
JSON out of the boxYes, with polymorphism attributesConverter requiredConverter requiredConverter requiredNot without workVerify — likely needs a converter
Public API surfaceNamed types, additiveGeneric chain leaksNamed typesInterface + typesAnonymousOne named type

The row that decides your migration order is the JSON one.

Compiler Mechanics and the Gotchas Nobody Mentions in the Announcement

Exhaustiveness diagnostics and what happens when a library adds a fourth case

Exhaustiveness is a source-level guarantee, which makes it a binary-level hazard. Adding a case to a union in a library is a source-breaking change for every consumer, on purpose. That is the feature working. It also means shipping a public union is a commitment: you cannot add a case in a minor version without breaking downstream builds.

If you have ever been annoyed that adding a value to a public enum was technically fine but semantically dangerous, this is the inverse trade. Unions surface the danger at compile time, and charge you for it in versioning discipline.

Interaction with nullable reference types and null as a case

Two things to test, both of which have bitten every discriminated-union library I have used:

  1. Does a switch expression over the union require a null arm when Nullable is enabled? With sealed records this produces CS8524 — the compiler warns that the switch is not exhaustive for null inputs, even if you have covered all types.
  2. Can null be a case, and is it distinct from "the union value itself is null"?

If unions require a null arm to satisfy the compiler, expect a wave of null => throw new UnreachableException() arms across your codebase. Harmless, but it eats the ceremony budget the feature was supposed to save.

Equality, deconstruction, and whether unions are value types or reference types in practice

This is the single question I would answer before adopting, and the coverage does not answer it. Based on the public proposal, union declarations support both a reference-type form and a union struct form, and the choice changes everything:

QuestionReference-type unionStruct union
EqualityCompiler-generated value equality (likely)Field-wise equality, cheap
AllocationOne heap allocation per valueNone, unless boxed
Copy costReference copySize of the largest case payload
Use as a Dictionary keyFineNeeds a good GetHashCode

If you get value equality for free, a union is a drop-in replacement for a result record in most signatures. If you do not, expect to write Equals/GetHashCode overrides, which is exactly the ceremony you were trying to delete.

Boxing, allocation, and what this means for hot paths

For hot paths the rule is boring and worth repeating: a struct union that gets stored in object, IEnumerable<object>, or passed to an interface-typed parameter is boxed on every call. A reference-type union is a single allocation. Sealed records are also a single allocation.

So unions do not automatically make anything faster. What they do is make the allocation count predictable: one per value if it is a class, zero if it is a struct and you keep it in typed positions. If you care about hot paths, pick the struct form and then run your benchmark — do not assume.

Trimming and Native AOT implications for reflection-based pattern matching

This is where I would spend real time. Pattern matching by type in a switch is compiled, not reflected, so it trims fine. What does not trim fine is anything that resolves types at runtime: JSON polymorphism via [JsonPolymorphic], Activator.CreateInstance, and serializers that walk GetType() at runtime.

If unions ship with a reflection-based default converter, they will show up in your trim warnings and your AOT build. The mitigation is the pattern you should already be using: a JsonSerializerContext with source generation, so the converters are generated at compile time.

Serialization and Interop Are Where the Migration Actually Hurts

System.Text.Json behavior for unions versus polymorphic record hierarchies

Sealed record hierarchies already have a supported story: [JsonPolymorphic] plus [JsonDerivedType], with a $type discriminator.

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(OrderLookup.Found), "found")]
[JsonDerivedType(typeof(OrderLookup.NotFound), "notFound")]
[JsonDerivedType(typeof(OrderLookup.ValidationFailed), "validationFailed")]
public abstract record OrderLookup;

That is real, documented, and shipping today. For unions, I do not know what System.Text.Json does, and neither does the release coverage. Test this before you migrate anything that crosses a wire. The three outcomes I consider plausible are: a built-in converter with a discriminator, no built-in converter (you write one), or a converter that serializes the union container rather than the case, producing {"value": {...}}-style nesting that breaks every existing client.

Only the first is a non-event. The third is a wire-format change disguised as a refactor.

Source-generated JSON contexts and why they matter for AOT builds

For a Native AOT build, reflection-based serialization is not an option you can afford. The safe pattern is unchanged by unions:

AppJsonContext.cs
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(OrderLookup))]
[JsonSerializable(typeof(Order))]
public partial class AppJsonContext : JsonSerializerContext { }

If unions need a custom converter, that converter needs to be registered on the context via [JsonConverter(typeof(OrderLookupConverter))] on the type — which is a per-type annotation, not something you can bolt on later without touching the domain. Plan for it in the same PR as the union conversion, not the next one.

Wire-compatibility rules when a union replaces an existing DTO shape

The rule I would enforce: a union may replace an internal type freely, and may replace a DTO only if the serialized bytes are identical. Not "equivalent" — identical. Verify with a golden-file test, not by eye.

WireFormatTests.cs
[Fact]
public void NotFound_serializes_like_the_legacy_dto()
{
  var legacy = JsonSerializer.Serialize(new NotFoundDto { Id = "A-1" }, LegacyJson.Options);
  var union  = JsonSerializer.Serialize<OrderLookup>(new OrderLookup.NotFound("A-1"), AppJsonContext.Default.OrderLookup);

  Assert.Equal(legacy, union);
}

If that test fails, you have found a breaking change in a unit test instead of in a mobile client you cannot update.

OpenAPI/Swagger generation and client SDKs generated from union-shaped contracts

Unions do not have an OpenAPI equivalent. The closest thing is oneOf with a discriminator, which most generators map to a tagged union in TypeScript and to something ambiguous in Java and C#. If you expose unions on a controller action, expect generated clients to either flatten the cases into one type, generate a oneOf wrapper nobody can ergonomically use, or fail generation outright.

This is the strongest argument for the boundary rule: keep unions behind the controller, map to an explicitly-shaped response DTO at the edge. Your generated SDKs stay boring, and boring SDKs are a feature.

Migration Playbook: Grep, Convert, Then Delete the default Arm

Step 1 — find the workarounds with grep, not vibes

Do not ask the team what patterns they use; they will forget two. Measure it.

find-union-workarounds.sh
# OneOf and similar libraries
rg -n --glob '*.cs' 'OneOf<' src/

## hand-rolled result hierarchies
rg -n --glob '*.cs' ':s*(Result|OperationResult|Outcome)' src/
rg -n --glob '*.cs' 'abstract record w*Result' src/

## Try-pattern leakage
rg -n --glob '*.cs' 'bool Tryw+(.*out ' src/

## visitor machinery
rg -n --glob '*.cs' 'interface Iw+Visitor' src/

## tuple returns that should be closed sets
rg -n --glob '*.cs' '(bool w+, .*string? w+)' src/

Run it, count hits per project, and sort descending. That list is your migration order — highest count first, because that is where the ceremony tax is largest.

Step 2 — convert leaf-by-leaf and keep the public surface stable

Convert the innermost type first: the domain lookup, not the controller that consumes it. Keep the public method signature identical while you change the implementation, so nothing downstream knows.

OrdersService.cs — adapter keeps the old surface
// Public surface unchanged; the union stays internal.
public async Task<OrderLookupResult> LookupAsync(string id, CancellationToken ct)
{
  var result = await _repo.FindAsync(id, ct);      // returns the new union

  return result switch
  {
      // compiler-checked mapping from union -> legacy public type
      Found f => OrderLookupResult.Ok(f.Order),
      NotFound => OrderLookupResult.NotFound(id),
      ValidationFailed v => OrderLookupResult.Invalid(v.Errors),
      // no default arm — let the compiler complain
  };
}

That // no default arm comment is the entire migration strategy in one line.

Step 3 — delete the default: arm and let the compiler find the gaps

Do not do this in one commit. Do it in two.

First, make the diagnostic an error so the compiler actually stops you:

Directory.Build.props — surgical warning escalation
<Project>
<PropertyGroup>
  <!-- Escalate only the exhaustiveness diagnostics, not all warnings -->
  <WarningsAsErrors>$(WarningsAsErrors);CS8509;CS8524</WarningsAsErrors>
</PropertyGroup>
</Project>

Use WarningsAsErrors with specific codes rather than TreatWarningsAsErrors=true. Turning every warning into an error on a mature solution produces a build that fails for reasons unrelated to your migration, and the team reverts the whole thing.

Then delete the _ => arms in one pass, build, and let the compiler hand you the list. That list is the real inventory of "places that were silently swallowing a case," and it is usually longer than anyone predicted.

What not to migrate: unions in public APIs you cannot version

Do not put a union in a public API of a NuGet package with external consumers unless you are prepared to treat every added case as a breaking change. The exhaustiveness guarantee that makes unions valuable internally makes them expensive externally.

Same for anything on the wire. If a mobile client you shipped six months ago parses the JSON, the response shape has to stay a plain DTO with explicit properties until that client is gone.

CoreCLR on MAUI: What Changes for Build and Startup

What the coverage says versus what the runtime swap implies for existing apps

The coverage says CoreCLR is now the MAUI runtime. Everything past that sentence is my inference, and I want to be explicit about which parts.

Confirmed: the runtime changed. Inferred, untested: what that does to startup time, app size, AOT availability, and reflection behavior for existing apps. Runtime swaps have historically moved all four. Whether they moved favorably this time is a measurement question, not a reading question.

Startup cost measurement method: machine, command, and how to reproduce it

I did not have a .NET 11 MAUI build available, so I am giving you the method and the exact output shape to look for rather than a fabricated number. A number without a device model and an SDK version is not a result.

measure-startup.sh
# 1. Build a release package for a physical device, not the emulator.
dotnet publish -f net11.0-android -c Release -p:AndroidPackageFormat=apk

## 2. Install it.
adb install -r bin/Release/net11.0-android/publish/com.example.app-Signed.apk

## 3. Cold start, five times, discarding the first.
adb shell am force-stop com.example.app
adb shell am start -W -n com.example.app/crc64xxxx.MainActivity

The output you are reading is this shape:

Starting: Intent { cmp=com.example.app/crc64xxxx.MainActivity }
Status: ok
LaunchState: COLD
Activity: com.example.app/crc64xxxx.MainActivity
TotalTime: <cold start, ms>
WaitTime: <total, ms>

Record TotalTime for runs 2–5 on the same device, same thermal state, screen on, airplane mode. Compare .NET 10 and .NET 11 builds of the same commit. If you cannot produce that pair, you do not have a startup claim — you have an impression.

Publish and AOT settings that likely need revisiting after the runtime change

These are the knobs I would re-read rather than copy forward:

SettingWhy the runtime swap makes it suspect
<PublishAot>true</PublishAot>AOT support is runtime-specific; verify the MAUI + CoreCLR combination is supported before relying on it
<PublishTrimmed>true</PublishTrimmed>Trimmed apps get smaller but break on reflection; a new runtime changes what is trimmed
<InvariantGlobalization>true</InvariantGlobalization>Affects size and startup, but changes culture behavior — test date and currency formatting
<AndroidEnableProfiledAot>Profile-guided AOT depends on the runtime's AOT pipeline
<MtouchLink> / trim modeLinker behavior is tied to the runtime's metadata handling

Every one of those is a likely item. Run dotnet publish for your real target, then install and smoke-test. Do not assume a green CI build means a working app — trimming failures are runtime failures.

Which existing workarounds and reflection-heavy code become risky under the new runtime

The reflection-heavy patterns from earlier in this post are the ones to audit:

  • Source-generated unions (Dunet and similar). Source generators run at build time, not runtime, so they are mostly safe — but verify the generator's Roslyn dependency against the new SDK before you assume that.
  • Reflection-based JSON. [JsonPolymorphic] without a source-generated context is a trim risk. This was true before; it is more likely to bite after a runtime change.
  • Activator.CreateInstance and assembly scanning. Any DI registration or plugin loader that scans assemblies is a trim and AOT risk. Grep for it.
  • Visitor implementations passed as interfaces. If a visitor is a struct implementing an interface, it boxes on every dispatch. Under a new runtime that is not a correctness problem, just a cost you should measure rather than assume.

SDK and CLI Changes That Hit CI Pipelines

Pinning the SDK: global.json and why floating versions break builds

If your pipeline uses dotnet-version: 11.0.x, your build changes when Microsoft ships a patch. Most of the time that is what you want. During a major-version transition it is not, because the failure you will spend a day on is a patch-level behavioral difference you did not choose.

global.json
{
"sdk": {
  "version": "11.0.100",
  "rollForward": "latestFeature"
}
}

latestFeature lets patch and feature-band updates in, but pins the major and minor. For a multi-targeted solution that still builds net10.0, keep the .NET 10 SDK available too and let global.json decide which one builds which project.

Workload restore and the cache keys that must change

dotnet workload restore is the slowest step in most MAUI pipelines and the one most often cached incorrectly. If your cache key is something like ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}, it does not include the SDK version — so a cache populated on .NET 10 will be reused on .NET 11 and produce a restore that succeeds while installing the wrong workload manifests.

Include the SDK version in the key:

.github/workflows/ci.yml (excerpt)
- name: Read SDK version
id: sdk
run: echo "version=$(jq -r .sdk.version global.json)" >> "$GITHUB_OUTPUT"

- uses: actions/cache@v4
with:
  path: ~/.dotnet
  key: dotnet-${{ runner.os }}-${{ steps.sdk.outputs.version }}-${{ hashFiles('**/Directory.Packages.props') }}

- run: dotnet workload restore

Where the extra build minutes go and how to measure them per stage

Do not guess. Produce a binary log and read the target timings.

profile-the-build.sh
dotnet build MySolution.sln -c Release -bl:build.binlog
## open build.binlog in the MSBuild Structured Log Viewer,
## sort targets by exclusive duration, and compare the two SDKs.

The stages I would compare first, in order: Restore, ResolvePackageAssets, CoreCompile, ILLink/AOT, and the platform packaging target. If the release claims faster tooling, the delta should be visible in Restore and CoreCompile. If it is not visible there, the speedup is somewhere else in the product and will not help your pipeline.

A minimal pipeline diff for a multi-targeted solution

diff — CI setup step
- - uses: actions/setup-dotnet@v4
-   with:
-     dotnet-version: 10.0.x
+ - uses: actions/setup-dotnet@v4
+   with:
+     dotnet-version: |
+       10.0.x
+       11.0.x
+
+ - name: Restore workloads for the pinned SDK
+   run: dotnet workload restore
+
+ - name: Build with exhaustiveness diagnostics as errors
+   run: dotnet build -c Release -warnaserror:CS8509,CS8524

That is the whole CI change: two SDKs installed, workloads restored explicitly against the pinned one, and one flag that makes the union migration self-enforcing.

Risks, Rollback, and the Honest Limits of This Comparison

What I could not verify from the release coverage

Being explicit, because a post about compiler guarantees should not itself be sloppy:

  • The exact union declaration syntax and whether the shipped form matches the public proposal.
  • Whether missing-case diagnostics are warnings or errors, and whether union struct shipped.
  • Whether System.Text.Json has first-party union support.
  • Any numeric claim about tooling speed.
  • Any numeric claim about MAUI startup or app size under CoreCLR.
  • Whether the TFM is net11.0 and the Android TFM is net11.0-android — highly likely, but confirm against the SDK.
  • Breaking changes in dotnet workload behavior across the major version.

Any of those could invalidate part of this post. The shape of the argument survives either way: closed sets beat open conventions, exhaustiveness beats discipline, and boundary stability beats internal elegance.

Rollback plan for teams that adopt unions in internal APIs only

The rollback is cheap by construction, and that is the reason to scope adoption to internals.

  1. Convert one project, behind an unchanged public surface (the adapter from Step 2 above).
  2. If the union's serialization or allocation behavior disappoints, revert that commit. Consumers never saw the change.
  3. If migration stalls halfway, you have a codebase with two representations and one adapter method per boundary — annoying, not broken.
  4. Delete the default: arms as a separate commit so step 2 and step 4 can be reverted independently.

The rule that makes this work: a union must never appear in a signature that a different assembly depends on during the trial period. Keep it internal, or public only within an assembly nobody else references.

Performance claims without published methodology should be treated as directional

The coverage says tooling is faster. It does not say faster by how much, on what solution size, with what hardware, or measured how. Those are the four things a performance claim needs to be actionable.

My rule: a perf claim with no methodology is a signal to try it, not a reason to plan around it. Run -bl on your own solution, compare Restore and CoreCompile timings, and use your number. Same for MAUI startup. Same for union allocations — measure GC.GetAllocatedBytesForCurrentThread around the dispatch, or do not claim an allocation win.

The Position: Adopt Unions Internally, Be Conservative at the Boundary

Where union types are a clear win

  • Domain results. An order lookup, a payment authorization, a validation outcome. Closed, small, and the exhaustiveness diagnostic pays for itself the first time someone adds a case.
  • Replacing OneOf in internal signatures. You get a named type, exhaustiveness, and one fewer NuGet dependency on your contract.
  • Replacing visitor hierarchies for case dispatch. Visitor is the right pattern when you add operations, not cases. Most of us add cases.
  • Replacing (bool, T, string) tuples where there are genuinely more than two outcomes.

Where the old workarounds are still the right answer

  • Public API surfaces in versioned packages. A union makes adding a case a breaking change. Sealed records plus a documented default arm at least let you version gracefully.
  • Wire contracts. Plain DTOs with explicit properties until a union has verified, byte-identical serialization and generated-client support. That day may come; plan for today.
  • Two-case results. A bool/out pair or an Option<T> is honestly clearer than a union with two members. Do not add a type to express "yes or no."
  • Anything on a hot path you have not measured. A reference-type union is one allocation — same as the record it replaced. You will not get a free win, and a struct union that gets boxed is worse than what you have.

The rule I would put in a team style guide

Unions are for closed sets inside an assembly. Anything crossing a process boundary gets an explicit DTO and a golden-file test proving the bytes did not change.

That sentence captures the entire post. Internally, unions remove ceremony and turn a runtime bug into a compile error. Externally, they move a versioning problem from your release notes into your consumers' build logs, and that trade is bad for everyone who is not on your team.

The other two headline changes deserve the same discipline. Pin the SDK, put the SDK version in your cache keys, and measure your own startup instead of trusting a release note. The language feature is the one you can adopt confidently, because the compiler will tell you when you get it wrong.

Further Reading

Share this post

More posts

Loading comments…