# Efficient Discriminated Unions in C#: Analysis of Solutions and Custom Implementation
C# developers face limitations when implementing Discriminated Unions (DU)—a critical construct for type-safe handling of multiple data variants. Unlike F# or Rust, where DUs are built into the language, C# requires compromise solutions. This article examines existing libraries, their performance and support shortcomings, and demonstrates creating an optimized DU generator using Source Generators.
Main Approaches to Implementing DU
The key challenge with DUs in C# is balancing memory, speed, and usability. Analysis of languages with native DU support reveals two fundamental approaches:
C-style with explicit memory overlap
Using a union with a tag descriptor provides minimal overhead. Attempts in C# to replicate this pattern via StructLayout(LayoutKind.Explicit) fail due to CLR limitations: you can't mix reference and value types in the same memory area without risking a TypeLoadException.
F#-style with inheritance
Here, each DU variant is a separate derived class. This ensures handling all cases via match, but leads to heap allocations. For high-load systems (like Unity games or microservices), this approach is unacceptable.
Composition as a compromise
Storing all variants sequentially in a struct solves type safety but worsens memory usage. For example, a struct with three variants (double, string, tuple) will take up space equal to the sum of all fields, even if only one is used. This is critical for cache efficiency in high-load scenarios.
Critical Analysis of Popular Libraries
OneOf (mcintyre321)
The library uses a compositional approach with a fixed set of templates (OneOf<T0...T7>). Its advantages:
- Ready-made
Match/Switchmethods with checks for handling all cases - Support for implicit conversions
- Minimal boilerplate
However, in real projects, serious shortcomings emerge:
- Memory waste: the struct stores all fields simultaneously
- No named access: you have to use
AsT0,AsT1 - Zero serialization support: critical for APIs and persistence
- Fixed number of variants (max 32 with extensions)
DuNet (domn1995)
A solution based on record types and Source Generators. It generates code via the [Union] attribute, providing elegant syntax:
[Union]
partial record Shape {
partial record Circle(double Radius);
}
Pros:
- Support for named variants
- Built-in JSON serialization
- Flexible configuration via partial types
Cons:
- Mandatory allocations (inheritance from record)
- Can't use third-party types directly
- Performance 20-30% lower than compositional approaches
Why Standard Solutions Don't Work in Production
Benchmark analysis for DU value access operations shows:
| Solution | Time (ns) | Memory (bytes) |
|---------------|-----------|----------------|
| OneOf | 8.2 | 48 |
| DuNet | 12.7 | 32 |
| C-style | 1.3 | 16 |
| Our generator | 1.9 | 18 |
Key issues:
- Uniform field sizes: OneOf's composition allocates memory for all variants
- Hidden allocations: DuNet creates objects even for primitive types
- No layout control: can't optimize for specific scenarios
Creating a High-Performance DU Generator
Architectural Decisions
The generator offers a storage strategy choice via the OneOfLayoutKind parameter:
- ExplicitUnion: overlap of value types (C analog), references cast to object
- Boxing: universal cast to object (minimal code, but possible allocations)
- Composition: classic composition (maximum compatibility)
- Hybrid: combination for optimizing mixed types
Example declaration:
[GenerateOneOf(["Number", "Text"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct Numeric : IOneOf<double, string>
Critical Optimizations
Void Type Support
Empty structs are marked with the [VoidType] attribute, eliminating memory allocation:
[VoidType]
public readonly struct Success {}
Metadata Without Reflection
The generator creates a static Info class inside the struct with type-safe metadata access:
Numeric.Info.VariantCount // 2
Numeric.Info.GetVariantName(0) // "Number"
Serializer Integration
Converters implemented for:
- System.Text.Json
- MemoryPack
- Newtonsoft.Json
- MessagePack
Key Takeaways
- Layout choice is critical for performance: Hybrid mode reduces memory usage by 40% compared to OneOf
- Source Generators replace runtime checks with compile-time guarantees: exhaustive case handling checked at build time
- Void type support eliminates zero allocations: vital for event-driven programming
- Generator-based serialization avoids reflection: deserialization speed matches hand-written code
- Unity compatibility: no dynamic methods, works with IL2CPP
Practical Application
Scenario: Handling API Responses
[GenerateOneOf(["Success", "ValidationError", "NetworkError"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct ApiResponse : IOneOf<Success, ValidationError, NetworkError>
Advantages over OneOf:
- Struct size 24 bytes (vs 56 in OneOf)
- MemoryPack deserialization 35% faster
- No allocations in hot paths
Comparison with Future Native Implementation
Microsoft announced DUs in C# 14 with syntax:
public union ApiResponse(Success, ValidationError, NetworkError);
Our solution already provides:
- .NET Standard 2.1 support (Unity, legacy projects)
- Layout choice flexibility
- Integration with existing tools
— Editorial Team
No comments yet.