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.

By 0xScorPionSon

11 min read

Inside C# 15 Union Types: Technical Breakdown of Discriminated Unions, Closed Hierarchies, and Exhaustive Pattern Matching

C# 15 Union Types and Discriminated Unions Architecture Infographic

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:

  1. Nominal Discriminated Unions: Declaring a fixed, closed set of variants using the native union keyword, providing compiler-enforced state isolation without runtime class sprawl.
  2. Exhaustive Pattern Matching: The Roslyn compiler mathematically verifies that switch expressions handle every declared union variant. Unhandled cases trigger compile-time warnings and errors, eliminating defensive runtime exceptions like throw new InvalidOperationException("Unreachable case").
  3. 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.
  4. Closed Class Hierarchies: The new closed class modifier restricts inheritance to a known set of subclasses declared within the same compilation unit, extending exhaustive matching guarantees to object-oriented domain models.
  5. 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 (switch IL 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

  1. Identify Candidate State Machines: Audit domain entities currently modeled using enums paired with loosely typed nullable properties (e.g., an Order class with StatusEnum, TrackingNumber?, and FailureReason?).
  2. Refactor to Nominal Unions: Replace loose properties with a single nominal union property where each state carries strictly its relevant payload.
  3. Replace OneOf Dependencies: Gradually deprecate third-party OneOf packages in internal libraries by converting method return types to native Result<T, E> unions.

Phase 3: Serialization and API Gateway Verification

  1. Implement Explicit JSON Converters: Ensure public API endpoints returning union types register dedicated JsonConverter implementations to serialize variant discriminators cleanly.
  2. OpenAPI / Swagger Schema Alignment: Verify that OpenAPI generators emit schema oneOf or anyOf polymorphic 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

  1. 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
  2. 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/
  3. 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/
  4. 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/
  5. 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

More on Technology

Technology

PostgreSQL 18.7 Patches 52 Security Vulnerabilities and 140+ Bugs as PostgreSQL 14 Reaches End of Life

PostgreSQL has shipped cumulative minor updates (18.7, 17.12, 16.16, 15.20, and 14.25) alongside 19 RC 2, patching 52 security vulnerabilities and 140+ bugs while marking permanent End of Life for PostgreSQL 14. Here is the technical breakdown of the CVSS 8.8 type-confusion and memory-safety fixes, mandatory post-update index and backup repairs, and migration steps.

12 min read

Technology

Node.js November 2026 Security Releases: Deconstructing HTTP/2 Remote Aborts, HTTPS Heap Leaks, and TLS Race Conditions

On November 11, 2026, the Node.js project released security updates across all active LTS lines (Node.js 26.13.1, 24.22.3, and 22.23.5) addressing 16 security vulnerabilities, including 7 rated High severity. Central to the advisory are severe networking defects: an unauthenticated HTTP/2 remote process termination via reachable assertion failures in ~Http2Session() (CVE-2026-123863), a zero-length TLS write re-entrancy flaw that leaks private keys and adjacent request bodies into HTTP responses (CVE-2026-123864), and an unhandled deferred-callback use-after-destroy in TLS server handshakes (CVE-2026-126051). This security engineering analysis breaks down the C++ socket teardown mechanics, protocol parsing flaws in nghttp2 and llhttp, affected production environments, and the enterprise patch playbook.

12 min read

Technology

Go 1.27 Architecture Deep Dive: Native SIMD Vectorization, Green Tea GC, and the Death of Assembly Stubs

Marking Go's 17th anniversary, the Go engineering team unveiled major runtime and compiler breakthroughs in Go 1.26 and 1.27. We deconstruct the experimental simd and simd/archsimd vector packages across amd64, arm64, and wasm, analyze Green Tea GC span-marking with AVX-512 acceleration, evaluate size-specialized memory allocation, and explore encoding/json/v2 streaming interfaces.

12 min read

Tell us what is not working

A thirty-minute call, no deck and no obligation. You will leave with a straight answer about whether this is worth building - including if that answer is no.

Start a project

Direct line

hello@codebumble.net
Dhaka, Bangladesh  :   local timeCalls in English or Bangla