Technology
Inside C# 15 Union Types: Technical Breakdown of Discriminated Unions, Closed Hierarchies, and Exhaustive Pattern Matching
On November 10, 2026, Microsoft officially released C# 15 alongside .NET 11, introducing native union types (discriminated unions) and closed hierarchies. By replacing open class sprawl and third-party libraries with compiler-enforced exhaustive pattern matching and zero-allocation tagged structs, C# 15 fundamentally modernizes domain modeling. Here is the technical breakdown, IL lowering mechanics, and enterprise migration guide.
11 min read
Inside C# 15 Union Types: Technical Breakdown of Discriminated Unions, Closed Hierarchies, and Exhaustive Pattern Matching
Executive Summary
On November 10, 2026, Microsoft launched C# 15 alongside .NET 11 during .NET Conf 2026. For over fourteen years—dating back to the introduction of modern pattern matching proposals following C# 6.0 and C# 7.0—the developer community has consistently ranked native discriminated unions as the single most requested language capability. With C# 15, Microsoft delivers this milestone through first-class Union Types and Closed Hierarchies, transforming how domain-driven designs, enterprise workflows, and error-handling pipelines are modeled in managed code.
Historically, C# developers seeking sum types (discriminated unions) were forced to choose between flawed compromises: clumsy object-oriented class hierarchies with unsealed inheritance, external third-party NuGet dependencies like OneOf, or untyped tuples and complex visitor patterns. In C# 15, the language introduces native nominal discriminated unions via the union keyword, backed by extensive Roslyn compiler lowering and runtime metadata support in the Base Class Library (BCL).
The core technical advancements of C# 15 union types and closed hierarchies include:
- Nominal Discriminated Unions: Declaring a fixed, closed set of variants using the native
unionkeyword, providing compiler-enforced state isolation without runtime class sprawl. - Exhaustive Pattern Matching: The Roslyn compiler mathematically verifies that
switchexpressions handle every declared union variant. Unhandled cases trigger compile-time warnings and errors, eliminating defensive runtime exceptions likethrow new InvalidOperationException("Unreachable case"). - Optimized Intermediate Language (IL) Lowering: The compiler emits lightweight tagged structs equipped with integer discriminator tags and compact payload storage, avoiding unnecessary heap allocations and GC pressure during state transitions.
- Closed Class Hierarchies: The new
closedclass modifier restricts inheritance to a known set of subclasses declared within the same compilation unit, extending exhaustive matching guarantees to object-oriented domain models. - Architectural Purity in Error Handling: Native support for functional
Result<T, E>patterns replaces expensive exception throwing for expected business failure states, significantly reducing thread unwinding overhead on high-throughput microservices.
This technical breakdown examines the ten architectural mechanics of C# 15 union types, detailing IL lowering, memory layouts, pattern matching rules, and enterprise migration strategies.
Technical Mechanics and Architecture
To understand how C# 15 implements discriminated unions, one must contrast conventional open class inheritance with nominal sum type compilation.
+-------------------------------------------------------------------------------+
| CONVENTIONAL C# OOP (OPEN INHERITANCE) |
+-------------------------------------------------------------------------------+
abstract class PaymentStatus { }
class Pending : PaymentStatus { }
class Completed : PaymentStatus { }
class Failed : PaymentStatus { }
class Chargeback : PaymentStatus { } // Can be extended anywhere in any assembly!
|
v [Compiler Limitation]
Switch Expression: Must always include `_ => throw new UnreachableException()`
* Open hierarchy: Compiler cannot prove no other subclasses exist.
* Heap allocation: Every state transition allocates a new class instance.
+-------------------------------------------------------------------------------+
| C# 15 NOMINAL UNION TYPE ARCHITECTURE |
+-------------------------------------------------------------------------------+
public union PaymentStatus {
Pending,
Completed(DateTime Timestamp, string TransactionId),
Failed(int ErrorCode, string Reason),
Disputed(decimal Amount)
}
|
v [Roslyn C# 15 Compiler Lowering]
Emits: Value-type tagged struct with discriminator integer + payload field:
+-----------------------------------------------------------------------------+
| Tag (int: 0..3) | Payload (Object / Overlapped memory reference) |
+-----------------------------------------------------------------------------+
|
+---> Exhaustive Switch Validation:
switch (status) {
case PaymentStatus.Pending: ...
case PaymentStatus.Completed(var time, var txId): ...
case PaymentStatus.Failed(var code, var msg): ...
case PaymentStatus.Disputed(var amount): ...
} // ZERO default case required! 100% mathematically proven complete.
The 10 Hidden Compiler Tricks Powering C# 15 Union Types
The C# 15 union type specification introduces ten distinct compiler mechanics that combine functional type safety with high-performance .NET execution semantics:
1. Nominal Sum Types via the union Keyword
Unlike structural union types (such as those in TypeScript, where A | B represents any shape matching either type), C# 15 adopts a nominal, named declaration model:
public union OrderResult
{
Success(Guid OrderId, decimal Total),
OutOfStock(string Sku, int AvailableQuantity),
PaymentFailed(string ErrorMessage)
}
Nominal declarations ensure full binary compatibility across assembly boundaries and allow the CLR type loader to inspect type identity without structural inference ambiguity.
2. Zero-Allocation Tagged Struct Lowering
Under the hood, Roslyn compiles an inline union into a readonly struct containing an internal integer __tag field alongside an internal payload storage field. For reference-type variants, the compiler shares a single object? storage slot, dynamically casting to the specific variant upon pattern matching. This prevents heap allocations during state transitions, making union evaluation allocation-free on standard execution paths.
3. Strict Compile-Time Exhaustiveness Checking
When evaluating a union with a switch expression, the compiler inspects the declared variant manifest. If any case is omitted, Roslyn emits diagnostic warning CS8509 (or an error under <TreatWarningsAsErrors>true</TreatWarningsAsErrors>). Once all declared cases are matched, no default discard arm (_ => ...) is needed, eliminating dead fallback code.
4. Closed Class Hierarchies via the closed Keyword
In addition to value-type unions, C# 15 introduces the closed modifier for reference-type inheritance hierarchies:
public abstract closed class DomainEvent
{
private DomainEvent() { } // Disallows external inheritance
public sealed class Created(Guid Id) : DomainEvent;
public sealed class Updated(Guid Id, string Name) : DomainEvent;
public sealed class Deleted(Guid Id) : DomainEvent;
}
Marking an abstract class as closed informs the compiler that the inheritance tree is strictly sealed within the declaring assembly. This allows reference-type domain events to enjoy identical exhaustive pattern matching guarantees.
5. Seamless Pattern Matching with Type Deconstruction
Union variants automatically generate compiler-synthesized positional deconstructors (Deconstruct methods). Developers can extract nested payload data directly within pattern expressions:
string summary = result switch
{
OrderResult.Success(var id, var total) when total > 1000m => $"VIP Order {id}: ${total}",
OrderResult.Success(var id, var total) => $"Standard Order {id}: ${total}",
OrderResult.OutOfStock(var sku, 0) => $"Item {sku} is entirely out of stock.",
OrderResult.OutOfStock(var sku, var qty) => $"Item {sku} only has {qty} units remaining.",
OrderResult.PaymentFailed(var err) => $"Declined: {err}"
};
6. Generic Union Types and Functional Result<T, E> Pipelines
C# 15 natively supports generic type parameters on union declarations, enabling enterprise teams to construct type-safe functional error-handling pipelines:
public union Result<T, E>
{
Ok(T Value),
Err(E Error)
}
This pattern replaces the anti-pattern of throwing exceptions for anticipated business rejections (such as invalid user inputs or credit check failures), preserving stack-unwinding performance.
7. Implicit Conversion Operators from Variant Cases
To eliminate verbose instantiation boilerplate, the compiler automatically synthesizes implicit conversion operators from each variant case into the enclosing union type:
public static OrderResult ProcessOrder(OrderDto order)
{
if (order.Quantity <= 0)
return OrderResult.PaymentFailed("Invalid quantity"); // Automatically implicitly converted!
return OrderResult.Success(Guid.NewGuid(), order.Price * order.Quantity);
}
8. BCL Metadata Integration via [Union] and [UnionCase]
To preserve interoperability with reflection, serializers, and tooling, Roslyn decorates emitted types with standardized BCL attributes ([Union] and [UnionCase]). Reflection libraries can query typeof(OrderResult).GetCustomAttributes<UnionCaseAttribute>() to inspect declared cases at runtime.
9. Absence of Value-Type Boxing on Reference Discriminators
When variants contain heterogeneous primitive types and reference types, the compiler's lowered memory layout avoids boxing primitives whenever possible. By overlapping primitive layout offsets in composite union structs or utilizing specialized value carriers, Roslyn maintains deterministic memory layouts.
10. System.Text.Json Serialization Boundaries and Workarounds
In the initial .NET 11 / C# 15 release, System.Text.Json does not yet possess automatic out-of-the-box type discriminator converters for native unions without custom configuration. Until polymorphic serialization lands natively in future servicing releases, production services must register explicit JsonConverter<T> handlers to map union tags to JSON $type discriminators.
Defensive Implementation and Verification Code
The following C# implementation demonstrates a production-grade domain service using C# 15 union types, complete with exhaustive pattern matching, functional error modeling, and a defensive JSON converter.
// DomainOrderService.cs
// Production implementation demonstrating C# 15 Union Types
// Target: .NET 11.0 / C# 15
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CodeBumble.DomainArchitecture
{
// C# 15 Nominal Union Type declaration
public union ProcessingStatus
{
Queued(DateTime EnqueuedAt),
Active(int WorkerId, double ProgressPercentage),
Completed(string OutputLocation, long ElapsedMs),
Failed(int ErrorCode, string ErrorDetails)
}
public class OrderWorkflowEngine
{
public static string EvaluateStatus(ProcessingStatus status)
{
// Fully exhaustive pattern matching without default arm
return status switch
{
ProcessingStatus.Queued(var time) =>
$"Job queued at {time:O}",
ProcessingStatus.Active(var worker, var pct) =>
$"Worker {worker} is processing ({pct:F1}% complete)",
ProcessingStatus.Completed(var loc, var ms) =>
$"Processing completed in {ms}ms. Destination: {loc}",
ProcessingStatus.Failed(var code, var details) =>
$"Processing error {code}: {details}"
};
}
public static ProcessingStatus AdvanceJob(ProcessingStatus current)
{
return current switch
{
ProcessingStatus.Queued =>
ProcessingStatus.Active(WorkerId: 101, ProgressPercentage: 0.0),
ProcessingStatus.Active(var w, var pct) when pct >= 100.0 =>
ProcessingStatus.Completed("/data/export.parquet", 1240),
ProcessingStatus.Active(var w, var pct) =>
ProcessingStatus.Active(w, pct + 25.0),
ProcessingStatus.Completed => current,
ProcessingStatus.Failed => current
};
}
}
// Defensive JSON serialization converter for C# 15 Union types
public class ProcessingStatusJsonConverter : JsonConverter<ProcessingStatus>
{
public override ProcessingStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
var caseType = root.GetProperty("case").GetString();
return caseType switch
{
"Queued" => ProcessingStatus.Queued(root.GetProperty("time").GetDateTime()),
"Active" => ProcessingStatus.Active(root.GetProperty("worker").GetInt32(), root.GetProperty("pct").GetDouble()),
"Completed" => ProcessingStatus.Completed(root.GetProperty("loc").GetString()!, root.GetProperty("ms").GetInt64()),
"Failed" => ProcessingStatus.Failed(root.GetProperty("code").GetInt32(), root.GetProperty("details").GetString()!),
_ => throw new JsonException($"Unknown ProcessingStatus variant: {caseType}")
};
}
public override void Write(Utf8JsonWriter writer, ProcessingStatus value, JsonSerializerOptions options)
{
writer.WriteStartObject();
switch (value)
{
case ProcessingStatus.Queued(var time):
writer.WriteString("case", "Queued");
writer.WriteString("time", time);
break;
case ProcessingStatus.Active(var worker, var pct):
writer.WriteString("case", "Active");
writer.WriteNumber("worker", worker);
writer.WriteNumber("pct", pct);
break;
case ProcessingStatus.Completed(var loc, var ms):
writer.WriteString("case", "Completed");
writer.WriteString("loc", loc);
writer.WriteNumber("ms", ms);
break;
case ProcessingStatus.Failed(var code, var details):
writer.WriteString("case", "Failed");
writer.WriteNumber("code", code);
writer.WriteString("details", details);
break;
}
writer.WriteEndObject();
}
}
}
Production Impact and Performance Benchmarks
The introduction of native union types directly improves memory safety and execution speed across enterprise services:
1. Allocation Reduction vs. Third-Party Libraries
In microbenchmarks comparing native C# 15 union types against the widely adopted OneOf<T0, T1, T2> library and traditional heap-allocated class hierarchies, native unions deliver measurable performance advantages:
- Heap Allocations: Native C# 15 value-type unions allocate 0 bytes on standard state-machine transformations, compared to 24–48 bytes per operation for open class hierarchies.
- Invocation Overhead: Evaluating exhaustive switch statements on native unions compiles down to direct jump tables (
switchIL opcodes on the integer tag), achieving parity with primitive integer switching. - Branch Elimination: The compiler's ability to prove completeness allows RyuJIT to eliminate dead branch paths and redundant bounds checks.
2. Elimination of Latent Production Outages
A persistent vulnerability in traditional object-oriented systems is the "unhandled subclass" bug. When a developer adds a new subclass to an open hierarchy, existing switch statements across disparate microservices fail silently or throw runtime exceptions when encountering the unexpected type. C# 15 turns this operational vulnerability into a compile-time blocker: adding a variant to a union breaks compilation immediately across all consuming switch sites, enforcing comprehensive business logic coverage before code can be deployed.
Enterprise Implementation Playbook
Migrating existing codebases to C# 15 union types requires a structured architectural approach:
Phase 1: SDK and Language Version Configuration
Ensure project files target .NET 11 and specify C# 15:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>15.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
Phase 2: Domain Modeling Refactoring
- Identify Candidate State Machines: Audit domain entities currently modeled using enums paired with loosely typed nullable properties (e.g., an
Orderclass withStatusEnum,TrackingNumber?, andFailureReason?). - Refactor to Nominal Unions: Replace loose properties with a single nominal union property where each state carries strictly its relevant payload.
- Replace OneOf Dependencies: Gradually deprecate third-party
OneOfpackages in internal libraries by converting method return types to nativeResult<T, E>unions.
Phase 3: Serialization and API Gateway Verification
- Implement Explicit JSON Converters: Ensure public API endpoints returning union types register dedicated
JsonConverterimplementations to serialize variant discriminators cleanly. - OpenAPI / Swagger Schema Alignment: Verify that OpenAPI generators emit schema
oneOforanyOfpolymorphic definitions matching the union variants for downstream client generators.
Frequently Asked Questions (FAQ)
How do C# 15 union types differ from F# discriminated unions?
While conceptually similar, C# 15 unions are designed specifically for C# language idioms and .NET runtime mechanics. C# unions compile into nominal value-type structs or closed class hierarchies that integrate directly with C# pattern matching syntax, whereas F# discriminated unions generate custom reference types tailored to F#'s module and functional conventions.
Can C# 15 union types contain methods, properties, or interfaces?
Yes. A C# 15 union type can declare computed properties, helper methods, and implement interfaces. However, union types cannot declare additional instance state fields outside of their declared variant cases, ensuring the memory footprint remains strictly governed by the variant payloads.
Does using a union type cause boxing when storing value types?
In cases where all declared variants are primitive value types or when variants share uniform memory representations, the Roslyn compiler minimizes boxing through composite layout structs. However, if a union mixes reference types and value types across generic parameters, value types may be stored in a shared object field.
What is the practical difference between a union and a closed class hierarchy?
A union is typically lowered as a lightweight, value-type tagged struct ideal for state machines, small payloads, and functional error handling. A closed class hierarchy is an object-oriented reference-type tree that allows traditional class semantics (such as virtual methods and reference identity) while enforcing compile-time exhaustiveness.
Are C# 15 union types supported in ASP.NET Core Minimal APIs?
Yes. ASP.NET Core in .NET 11 includes native parameter binding and endpoint response handling for C# 15 union types, allowing endpoints to return typed unions (such as Results<Ok<User>, NotFound, BadRequest>) with automatic HTTP status code mapping.
Intelligence Sources and References
- Microsoft Learn (November 10, 2026): What's new in C# 15 — Official language reference detailing union types, closed hierarchies, and compiler exhaustiveness checks.
https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15 - Microsoft Developer Blogs (November 10, 2026): Announcing .NET 11 by .NET Team — Launch announcement covering C# 15 union types, CoreCLR runtime integration, and SDK tooling.
https://devblogs.microsoft.com/dotnet/announcing-dotnet-11/ - Microsoft Developer Blogs: Explore union types in C# 15 by Bill Wagner — Comprehensive architectural deep dive into nominal sum types, syntax proposals, and type design.
https://devblogs.microsoft.com/dotnet/csharp-15-union-types/ - NDepend Architecture Blog (November 5, 2026 / November 10, 2026): C# 15 Unions: What the Compiler Generates and When to Use Them — Technical analysis of Roslyn struct lowering, IL tag emission, and exhaustiveness verification.
https://blog.ndepend.com/csharp-unions/ - AntonDevTips (November 10, 2026): New Features in .NET 11 and C# 15 — Enterprise application patterns, discriminated union examples, and migration best practices.
https://antondevtips.com/blog/new-features-in-dotnet-11-and-csharp-15