In the evolving landscape of programming languages, type systems continue to be a fundamental differentiator in how we express our code’s intent. One powerful feature that many modern languages offer, but C# has traditionally lacked, is union types (also known as sum types or discriminated unions). Today, we’ll explore what union types are, why they matter, and how we can implement them in C# using implicit conversion operators.
What Are Union Types?
At their core, union types represent values that could be of several different types. Unlike interfaces or base classes which enforce a common structure, union types allow a variable to hold completely different types. For example, a function might return either a successful result or an error message—two entirely different types with no shared interface.
Languages like F#, TypeScript, Rust, and Kotlin all provide native support for union types:
// F# discriminated union
type Result<'T> =
| Success of 'T
| Error of string
// TypeScript union type
type Result<T> = T | Error;
// Rust enum (which acts as a union type)
enum Result<T, E> {
Ok(T),
Err(E),
}
The Gap in C#
Despite its rich type system, C# doesn’t provide built-in support for union types. Typically, C# developers work around this limitation using:
- Class hierarchies – Creating base classes or interfaces and implementing specific types
- Optional properties – Using nullable fields in a single class
- The
dynamic
type – Sacrificing type safety entirely - Pattern matching with
object
– Boxing values intoobject
and checking types at runtime
All these approaches have drawbacks:
Boxing with object
impacts performance and requires type checks
Class hierarchies introduce complexity and tightly coupled code
Optional properties lead to invalid states and null checks
dynamic
eliminates compile-time type checking
Implementing Union Types with Implicit Conversion
Let’s implement a union type in C# using implicit conversion operators. Here’s our first approach:
public struct Union<T1, T2>
{
public Union() {}
public Union(T1 value)
{
Value = value;
}
public Union(T2 value)
{
Value = value;
}
object? Value { get; }
public bool Is<T>() => Value is T;
public static implicit operator T1(Union<T1, T2> instance) => (T1)instance.Value;
public static implicit operator T2(Union<T1, T2> instance) => (T2)instance.Value;
public static implicit operator Union<T1, T2>(T1 instance) => new Union<T1, T2>(instance);
public static implicit operator Union<T1, T2>(T2 instance) => new Union<T1, T2>(instance);
public override string? ToString() => Value?.ToString();
}
This allows us to work with values that could be either T1
or T2
:
// Can hold either an int or a string
Union<int, string> result = 42; // Implicit conversion from int
// Check the type before using
if (result.Is<int>())
{
int value = result; // Implicit conversion to int
Console.WriteLine($"Got an integer: {value}");
}
else if (result.Is<string>())
{
string text = result; // Implicit conversion to string
Console.WriteLine($"Got a string: {text}");
}
However, there are significant performance issues with this initial implementation:
- Boxing overhead: Value types are boxed when stored in the
object
field, causing heap allocations for every value type stored - Memory allocation: Every boxed value type creates garbage collection pressure
- Runtime casting: Each access requires an unsafe cast, which has both performance overhead and potential runtime failures
- Type erasure: The original type information isn’t preserved structurally, requiring runtime checks
To illustrate the performance impact, consider what happens when storing an int
value:
Union<int, string> result = 42;
In our first implementation:
- The
int
value 42 is boxed to the heap - The boxed reference is stored in the
Value
property - When we later access it as
int
, we need to cast and unbox it
When working with many value types or in performance-critical code, these operations create a significant overhead.
An Improved Implementation: Eliminating Boxing
Here’s an improved version that completely eliminates boxing by storing both possible types directly and efficiently tracking which one is active:
public struct Union<T1, T2>
{
private readonly T1 _value1;
private readonly T2 _value2;
private readonly byte _type; // 1 = T1, 2 = T2
public Union(T1 value)
{
_value1 = value;
_value2 = default!;
_type = 1;
}
public Union(T2 value)
{
_value1 = default!;
_value2 = value;
_type = 2;
}
public bool Is<T>() =>
_type == 1 && typeof(T) == typeof(T1) ||
_type == 2 && typeof(T) == typeof(T2);
public override string? ToString() =>
_type == 1 ? _value1?.ToString() :
_type == 2 ? _value2?.ToString() :
null;
public static implicit operator Union<T1, T2>(T1 value) => new(value);
public static implicit operator Union<T1, T2>(T2 value) => new(value);
public static implicit operator T1(Union<T1, T2> union) =>
union._type == 1 ? union._value1 : throw new InvalidOperationException($"Union does not contain a value of type {typeof(T1)}");
public static implicit operator T2(Union<T1, T2> union) =>
union._type == 2 ? union._value2 : throw new InvalidOperationException($"Union does not contain a value of type {typeof(T2)}");
}
The performance improvements in this implementation:
- No boxing: Value types are stored directly in their native form without boxing
- Zero heap allocations: All data is stored on the stack when using value types
- Direct access: The discriminator (
_type
) enables direct access to the correct field without casting - Cache-friendly: The entire structure can be cache-local, improving CPU performance
Using this improved version still follows the same pattern as before, but without the performance penalties:
Union<int, string> result = 42; // No boxing occurs
if (result.Is<int>())
{
int value = result; // No unboxing needed
Console.WriteLine($"Got an integer: {value}");
}
Adding Pattern Matching
Our implementation works, but checking types with Is<T>()
and then converting can be verbose and error-prone. Let’s add a powerful pattern matching capability to our union type:
// Add this method to the improved Union<T1, T2> struct
public T Match<T>(Func<T1, T> matchT1, Func<T2, T> matchT2)
{
if (_type == 1)
return matchT1(_value1);
else if (_type == 2)
return matchT2(_value2);
throw new InvalidOperationException("Union is in an invalid state");
}
This method takes two functions: one to handle T1
values and another to handle T2
values. It then invokes the appropriate function based on the type of value currently held. This enables a functional-style pattern matching approach that’s both concise and type-safe.
Here’s how we can use it:
Union<int, string> result = someOperation();
// Pattern matching style
string message = result.Match(
number => $"Got number: {number}",
text => $"Got text: {text}"
);
Console.WriteLine(message);
The Match
method guarantees that all possible types are handled, making your code more robust. If you add a third type to your union in the future, the compiler will force you to update all match expressions.
Extending with LINQ-like Operations
We can make our union type even more powerful by adding LINQ-like mapping functions:
// Add these methods to the improved Union<T1, T2> struct
// Map/Select for T1
public Union<TResult, T2> MapFirst<TResult>(Func<T1, TResult> mapper)
{
return _type == 1
? new Union<TResult, T2>(mapper(_value1))
: new Union<TResult, T2>(_value2);
}
// Map/Select for T2
public Union<T1, TResult> MapSecond<TResult>(Func<T2, TResult> mapper)
{
return _type == 2
? new Union<T1, TResult>(mapper(_value2))
: new Union<T1, TResult>(_value1);
}
These methods allow you to transform one type in the union while preserving the other:
// Transform integers to their square, leave strings unchanged
Union<int, string> result = 5;
Union<int, string> transformed = result.MapFirst(x => x * x);
// transformed now contains 25
We can also add support for asynchronous operations:
// Add this method to the improved Union<T1, T2> struct
public async Task<T> MatchAsync<T>(
Func<T1, Task<T>> matchT1,
Func<T2, Task<T>> matchT2)
{
if (_type == 1)
return await matchT1(_value1);
return await matchT2(_value2);
}
With these extensions, our union type becomes a powerful tool for handling different types of values in a clean, functional style.
Real-World Example: Stripe’s Payment Sources
Now that we have a full-featured union type implementation, let’s see how it can solve a real-world problem: handling payment sources in the Stripe API.
When processing payments with Stripe, the Charge
object contains a source
field that can hold different payment method types. The actual type is identified by the object
property within the source object.
Here’s a simplified representation of what a Stripe charge looks like when using a card payment:
{
"id": "ch_3OxABC123def",
"object": "charge",
"amount": 2000,
"currency": "usd",
"source": {
"id": "card_1ABC123def",
"object": "card",
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2025,
"name": "Jenny Rosen"
}
}
But when the payment is made with a bank account, the structure changes dramatically:
{
"id": "ch_3OxDEF456ghi",
"object": "charge",
"amount": 2000,
"currency": "usd",
"source": {
"id": "ba_1DEF456ghi",
"object": "bank_account",
"bank_name": "STRIPE TEST BANK",
"last4": "6789",
"routing_number": "110000000",
"account_holder_name": "Jenny Rosen"
}
}
This is a perfect union type scenario – both are valid source
types but have completely different structures and properties.
The traditional approach to handle this would involve conditional logic and casting:
// Traditional approach - prone to errors and duplication
void ProcessPaymentTraditional(Charge charge)
{
var source = charge.Source;
string receipt;
if (source.Object == "card")
{
// Cast and use card-specific properties
var cardBrand = ((dynamic)source).Brand;
var last4 = ((dynamic)source).Last4;
receipt = $"Payment of ${charge.Amount/100}.00 made with {cardBrand} card ending in {last4}";
RecordCardPayment(cardBrand);
}
else if (source.Object == "bank_account")
{
// Cast and use bank-specific properties
var bankName = ((dynamic)source).BankName;
var last4 = ((dynamic)source).Last4;
receipt = $"Payment of ${charge.Amount/100}.00 made from {bankName} account ending in {last4}";
RecordBankPayment(bankName);
}
else
{
throw new ArgumentException($"Unsupported source type: {source.Object}");
}
SendReceiptEmail(charge.Customer.Email, receipt);
}
Now let’s solve this with our union type. First, we’ll define models for the different payment source types:
// Payment source models
public record CardSource(
string Id,
string Brand,
string Last4,
int ExpMonth,
int ExpYear,
string Name
);
public record BankAccountSource(
string Id,
string BankName,
string Last4,
string RoutingNumber,
string AccountHolderName
);
Next, we’ll create a function to parse the Stripe charge and return a strongly-typed union:
// Process Stripe charge response
public Union<CardSource, BankAccountSource> GetPaymentSource(Charge charge)
{
// Get the source from the charge
var source = charge.Source;
// Determine the source type and create appropriate model
if (source.Object == "card")
{
return new CardSource(
source.Id,
source.Brand,
source.Last4,
source.ExpMonth,
source.ExpYear,
source.Name
);
}
else if (source.Object == "bank_account")
{
return new BankAccountSource(
source.Id,
source.BankName,
source.Last4,
source.RoutingNumber,
source.AccountHolderName
);
}
throw new ArgumentException($"Unsupported source type: {source.Object}");
}
Finally, we can process the payment with clean, type-safe pattern matching:
// Process a payment using our union type
void ProcessPayment(Charge charge)
{
// Get the payment source as a union type
var source = GetPaymentSource(charge);
// Process based on payment type with type safety using pattern matching
string receipt = source.Match(
card => $"Payment of ${charge.Amount/100}.00 made with {card.Brand} card ending in {card.Last4}",
bank => $"Payment of ${charge.Amount/100}.00 made from {bank.BankName} account ending in {bank.Last4}"
);
// Send receipt
SendReceiptEmail(charge.Customer.Email, receipt);
// Record payment method for analytics using the same pattern matching
source.Match(
card => RecordCardPayment(card.Brand),
bank => RecordBankPayment(bank.BankName)
);
}
The union type approach provides several advantages:
- Type Safety: We get strongly-typed access to each payment source’s specific properties
- Exhaustive Handling: The
Match
pattern ensures we handle all payment source types - Clean Separation: Each payment method has its own dedicated model
- Maintainable Code: Adding a new payment method type just means adding a new type to the union
We can also leverage the LINQ-like extensions we built for more complex operations:
// Example: Normalize card brands to uppercase but leave bank names unchanged
var normalizedSource = source.MapFirst(card => new CardSource(
card.Id,
card.Brand.ToUpper(),
card.Last4,
card.ExpMonth,
card.ExpYear,
card.Name
));
And for asynchronous processing:
async Task ProcessPaymentAsync(Charge charge)
{
var source = GetPaymentSource(charge);
await source.MatchAsync(
async card => {
await RecordCardPaymentAsync(card);
await SendCardReceiptAsync(charge, card);
},
async bank => {
await RecordBankPaymentAsync(bank);
await SendBankReceiptAsync(charge, bank);
}
);
}
When to Use Union Types
Union types are particularly useful when:
- Working with external APIs: Like the Stripe API where responses contain different types
- Modeling state machines: When each state has different associated data
- Processing heterogeneous data: Working with mixed data types
- API boundaries: When different response types are possible
- Type-safe error handling: Better alternatives to exceptions or null returns
Conclusion
While C# doesn’t provide native union types, there is an open proposal to introduce them to the language. In the meantime, we can implement a powerful alternative using implicit conversion operators. Our implementation allows us to work with values that could be of different types without resorting to inheritance hierarchies or sacrificing type safety.
With this approach, we can bring some of the expressiveness of functional programming languages to our C# codebase while maintaining the performance characteristics we expect.