Discriminated Unions - PawelGerr/Thinktecture.Runtime.Extensions GitHub Wiki
- Introduction
- Requirements
- Getting started
- Ad hoc unions
- Regular unions
- Real-world use cases and ideas
Discriminated unions are a powerful feature that allows a type to hold a value that could be one of several different types. This is particularly useful when you need to:
- Represent a value that can be one of several distinct types
- Ensure type safety and exhaustive pattern matching
- Model domain concepts that have multiple distinct states
- Handle success/failure scenarios without exceptions
The library provides two types of unions:
- Ad hoc unions: Quick to implement, ideal for simple cases where you need to combine a few types
- Regular unions: More flexible, support inheritance and complex scenarios, perfect for modeling domain concepts
- C# 11 (or higher) for generated code
- SDK 8.0.400 (or higher) for building projects
Ad hoc unions are the simplest way to combine multiple types into a single type. They're perfect for scenarios where you need to:
- Represent a value that can be one of a few specific types
- Avoid using object or dynamic types
- Ensure type-safe access to the underlying value
Create a partial
class
, struct
or ref struct
and annotate it with UnionAttribute<T1, T2>
.
// class
[Union<string, int>]
public partial class TextOrNumber;
// struct
[Union<string, int>]
public partial struct TextOrNumber;
// ref struct
[Union<string, int>]
public ref partial struct TextOrNumber;
The UnionAttribute
allows up to 5 types:
[Union<string, int, bool, Guid, char>]
public partial class MyUnion;
The source generator creates a comprehensive set of features:
// Implicit conversion from one of the defined generics
TextOrNumber textOrNumberFromString = "text";
TextOrNumber textOrNumberFromInt = 42;
// Type checking
bool isText = textOrNumberFromString.IsString;
bool isNumber = textOrNumberFromString.IsInt32;
// Type-safe value access
// Throws "InvalidOperationException" if type doesn't match
string text = textOrNumberFromString.AsString;
int number = textOrNumberFromInt.AsInt32;
// Alternative: explicit casting
string text = (string)textOrNumberFromString;
int number = (int)textOrNumberFromInt;
// Get untyped value
object value = textOrNumberFromString.Value;
// Built-in equality comparison
bool equals = textOrNumberFromInt.Equals(textOrNumberFromString);
bool equal = textOrNumberFromInt == textOrNumberFromString;
bool notEqual = textOrNumberFromInt != textOrNumberFromString;
All Switch
/Map
methods are exhaustive by default ensuring all cases are handled correctly.
TextOrNumber union = "Hello, World!";
// With "Action"
union.Switch(@string: s => logger.Information("[Switch] String Action: {Text}", s),
int32: i => logger.Information("[Switch] Int Action: {Number}", i));
// With "Action". Logger is passed as additional parameter to prevent closures.
union.Switch(logger,
@string: static (l, s) => l.Information("[Switch] String Action with logger: {Text}", s),
int32: static (l, i) => l.Information("[Switch] Int Action with logger: {Number}", i));
// With "Func<T>"
var switchResponse = union.Switch(@string: static s => $"[Switch] String Func: {s}",
int32: static i => $"[Switch] Int Func: {i}");
// With "Func<T>" and additional argument to prevent closures.
var switchResponseWithContext = union.Switch(123.45,
@string: static (value, s) => $"[Switch] String Func with value: {value} | {s}",
int32: static (value, i) => $"[Switch] Int Func with value: {value} | {i}");
Use Map
instead of Switch
to return concrete values directly.
var mapResponse = union.Map(@string: "[Map] Mapped string",
int32: "[Map] Mapped int");
Use T1Name
/T2Name
to create more meaningful property names:
[Union<string, int>(T1Name = "Text",
T2Name = "Number")]
public partial class TextOrNumber;
// Properties use the custom names
bool isText = union.IsText;
bool isNumber = union.IsNumber;
string text = union.AsText;
int number = union.AsNumber;
Enable nullable reference types with T1IsNullableReferenceType
:
[Union<string, int>(T1IsNullableReferenceType = true)]
public partial class TextOrNumber;
// Now string is treated as nullable
TextOrNumber union = (string?)null;
string? text = union.AsString; // Type is string?
Configure string comparison behavior:
[Union<string, int>(DefaultStringComparison = StringComparison.Ordinal)]
public partial class TextOrNumber;
Disable ToString generation:
[Union<string, int>(SkipToString = true)]
public partial class TextOrNumber;
Control constructor accessibility:
This feature is useful in conjunction with
SkipImplicitConversionFromValue
for creation of unions with additional properties.
[Union<string, int>(ConstructorAccessModifier = UnionConstructorAccessModifier.Private)]
public partial class TextOrNumber;
Disable implicit conversions:
[Union<string, int>(
SkipImplicitConversionFromValue = true,
ConstructorAccessModifier = UnionConstructorAccessModifier.Private)]
public partial class TextOrNumberExtended
{
public required string AdditionalProperty { get; init; }
public TextOrNumberExtended(string text, string additionalProperty)
: this(text)
{
AdditionalProperty = additionalProperty;
}
public TextOrNumberExtended(int number, string additionalProperty)
: this(number)
{
AdditionalProperty = additionalProperty;
}
}
Enable partial matching with SwitchPartially
and MapPartially
:
[Union<string, int>(
SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial class TextOrNumber;
// Use partial coverage with default case
union.SwitchPartially(
@default: value => Console.WriteLine($"Default: {value}"),
@string: text => Console.WriteLine($"Text: {text}")
);
var result = union.MapPartially(
@default: "Default case",
@string: "Text"
);
While regular unions serialize to a polymorphic JSON object, ad hoc unions cannot be serialized due to missing type discriminator. However, there are scenarios where a union needs a custom serialization. For instance, you might want to represent a union as a single string
.
The library enables this custom conversion through the [ObjectFactoryAttribute<T>]
. By applying this attribute, you can define how your union is converted to and from another type, most commonly a string. This custom representation can then be used for JSON serialization, ASP.NET Core model binding, and Entity Framework Core persistence.
To implement a custom conversion, you must decorate the union with the [ObjectFactory<T>]
. The source generator will add one or two interfaces to your union that you need to implement. The method Validate
is for parsing the custom format and creating an instance of the union. The method ToValue
is for converting the union back into its custom representation. If the value is of type string
then the Source Generator will implement IParsable<TUnion>
automatically.
The TextOrNumber
example from previous sections illustrates this for an ad hoc union:
[Union<string, int>(T1Name = "Text", T2Name = "Number")]
[ObjectFactory<string>(
UseForSerialization = SerializationFrameworks.All, // JSON, MessagePack
UseForModelBinding = true, // Model Binding, OpenAPI
UseWithEntityFramework = true)] // Entity Framework Core
public partial class TextOrNumber
{
// For serialization (implementation of IConvertible<string>)
public string ToValue()
{
return Switch(text: t => $"Text|{t}",
number: n => $"Number|{n}");
}
// For deserialization (implementation of IObjectFactory<TextOrNumber, string, ValidationError>)
public static ValidationError? Validate(
string? value, IFormatProvider? provider, out TextOrNumber? item)
{
if (value.StartsWith("Text|", StringComparison.OrdinalIgnoreCase))
{
item = value.Substring(5); // successful deserialization of the text
return null;
}
if (value.StartsWith("Number|", StringComparison.OrdinalIgnoreCase))
{
if (Int32.TryParse(value.Substring(7), out var number))
{
item = number; // successful deserialization of the number
return null;
}
item = null;
return new ValidationError("Invalid number format");
}
item = null;
return new ValidationError("Invalid format");
}
}
With the [ObjectFactory<T>]
attribute, you define the custom conversion logic for a union. However, this attribute alone is not sufficient for framework integration. You must still perform the framework-specific setup described in the other sections. For example, you need to register a ThinktectureModelBinderProvider
for ASP.NET Core or use UseThinktectureValueConverters
for Entity Framework Core. The UseFor...
properties on the attribute simply instruct the library on how to handle the union once they are configured.
Entity Framework Core
Regular unions can be persisted using EF Core’s built-in inheritance. With Table-Per-Hierarchy (TPH) all cases are stored in a single table with a discriminator. With Table-Per-Type (TPT), every type gets a separate table.
Ad hoc unions can be persisted in Entity Framework Core using the same ObjectFactoryAttribute<T>
approach demonstrated earlier. By implementing the required interfaces, the union can be converted to and from a primitive type that EF Core can handle.
EF Core’s ValueConverter is used to bridge between the union type and its primitive representation.
// Entity containing the union
public class Document
{
public required TextOrNumber Content { get; set; }
}
public class DocumentDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Adds value converters
modelBuilder.AddThinktectureValueConverters();
}
}
ASP.NET Core Usage:
With IParsable<T>
implemented, the union can be used directly in controller actions:
[Route("api")]
public class DemoController : ControllerBase
{
[HttpGet("textOrNumber/{value}")]
public IActionResult RoundTrip(TextOrNumber value)
{
string representation = value.Switch(
text => $"Received text: {text}",
number => $"Received number: {number}"
);
return Ok(value);
}
}
This approach allows ad hoc unions to participate in serialization and model binding flows by leveraging the value object infrastructure.
Starting with version 9.4.0
, two new options are available for ObjectFactoryAttribute<T>
:
-
UseForModelBinding = true
: This enables the unions to use the typeT
for ASP.NET Core Model Binding. -
UseWithEntityFramework = true
: This enables the unions to use the typeT
for persistence with Entity Framework Core.
[ObjectFactory<string>(
UseForSerialization = SerializationFrameworks.All,
UseForModelBinding = true,
UseWithEntityFramework = true)]
public partial class TextOrNumber
{
...
}
Regular unions provide more flexibility through inheritance. They're ideal for:
- Domain modeling with distinct subtypes
- Complex hierarchies with shared behavior
- Integration with other features like value objects
Furthermore, the source generator implements an implicit conversion for every unique constructor parameter type. The generation of the conversion can be disabled the same way as with Ad hoc unions (see Skip implementation of implicit casts).
Simple union using inheritance:
[Union]
public partial class Animal
{
public sealed class Dog : Animal
{
public string Name { get; }
public Dog(string name) => Name = name;
}
public sealed class Cat : Animal
{
public string Name { get; }
public Cat(string name) => Name = name;
}
}
// Usage
Animal animal = new Animal.Dog("Rover");
// Pattern matching
animal.Switch(
dog: d => Console.WriteLine($"Dog: {d.Name}"),
cat: c => Console.WriteLine($"Cat: {c.Name}")
);
Same as above but using records
:
[Union]
public partial record AnimalRecord
{
public sealed record Dog(string Name) : AnimalRecord;
public sealed record Cat(string Name) : AnimalRecord;
}
Regular unions support complex scenarios:
// Generic unions
[Union]
public partial record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record Failure(string Error) : Result<T>;
}
// Usage
Result<int> result = await GetDataAsync();
var message = result.Switch(
success: s => $"Got value: {s.Value}",
failure: f => $"Error: {f.Error}"
);
Regular unions work seamlessly with value objects:
[Union]
public partial class Animal
{
[ValueObject<string>]
public partial class Dog : Animal;
[ValueObject<string>]
public partial class Cat : Animal;
}
// Both Dog and Cat get value object features
var dog = Animal.Dog.Create("Rover");
Here are some examples I used in the past to show the developers the benefits of discriminated unions.
Some use-cases need to handle dates with varying levels of precision.
For example, historical dates might only be known by year ("born in 1980") or by year and month ("occurred in June 1995").
The PartiallyKnownDate
discriminated union provides an elegant solution to this problem.
[JsonDerivedType(typeof(YearOnly), "Year")]
[JsonDerivedType(typeof(YearMonth), "YearMonth")]
[JsonDerivedType(typeof(Date), "Date")]
[Union]
public abstract partial record PartiallyKnownDate
{
public int Year { get; }
private PartiallyKnownDate(int year)
{
Year = year;
}
public sealed record YearOnly(int Year) : PartiallyKnownDate(Year);
public sealed record YearMonth(int Year, int Month) : PartiallyKnownDate(Year);
public sealed record Date(int Year, int Month, int Day) : PartiallyKnownDate(Year);
public static implicit operator PartiallyKnownDate(DateOnly dateOnly) =>
new Date(dateOnly.Year, dateOnly.Month, dateOnly.Day);
public static implicit operator PartiallyKnownDate?(DateOnly? dateOnly) =>
dateOnly is null ? null : dateOnly.Value;
}
Usage:
// Date with only year known
var historicalEvent = new PartiallyKnownDate.YearOnly(2000);
// Date with year and month known
var approximateBirthdate = new PartiallyKnownDate.YearMonth(1980, 6);
// Fully known date
var preciseDate = new PartiallyKnownDate.Date(2024, 12, 31);
// Implicit conversion from DateOnly to PartiallyKnownDate
PartiallyKnownDate fullDate = new DateOnly(2024, 3, 15); // Date(2024, 3, 15)
static string FormatDate(PartiallyKnownDate date)
{
return date.Switch(
yearOnly: y => y.Year.ToString(),
yearMonth: ym => $"{ym.Year}-{ym.Month:D2}",
date: ymd => $"{ymd.Year}-{ymd.Month:D2}-{ymd.Day:D2}"
);
}
logger.Information("Historical event: {Date}", FormatDate(historicalEvent)); // "2000"
logger.Information("Approximate birthdate: {Date}", FormatDate(approximateBirthdate)); // "1980-06"
logger.Information("Precise date: {Date}", FormatDate(preciseDate)); // "2023-12-31"
Json serialization is supported out of the box with JsonDerivedType
attributes.
// Json serialization
var json = JsonSerializer.Serialize<PartiallyKnownDate>(preciseDate);
logger.Information(json); // {"$type":"Date","Month":12,"Day":31,"Year":2023}
var deserializedDate = JsonSerializer.Deserialize<PartiallyKnownDate>(json);
logger.Information("Deserialized date: {Date}", FormatDate(deserializedDate)); // "2023-12-31"
Combination of value objects and union types.
Jurisdictions can be a country, a federal state or a district. The Unknown
type is used to represent an unknown jurisdiction.
[Union]
public abstract partial class Jurisdiction
{
[ValueObject<string>(KeyMemberName = "IsoCode")]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
[KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public partial class Country : Jurisdiction
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string isoCode)
{
if (string.IsNullOrWhiteSpace(isoCode))
{
validationError = new ValidationError("ISO code is required.");
isoCode = string.Empty;
return;
}
isoCode = isoCode.Trim();
if (isoCode.Length != 2)
validationError = new ValidationError("ISO code must be exactly 2 characters long.");
}
}
/// <summary>
/// Let's assume that the federal state is represented by an number.
/// </summary>
[ValueObject<int>(KeyMemberName = "Number")]
public partial class FederalState : Jurisdiction;
[ValueObject<string>(KeyMemberName = "Name")]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
[KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public partial class District : Jurisdiction;
/// <summary>
/// The complex type adds appropriate equality comparison(i.e. it checks for type only).
/// </summary>
[ComplexValueObject]
public partial class Unknown : Jurisdiction
{
public static readonly Unknown Instance = new();
}
}
Usage:
// Creating different jurisdictions
var district = Jurisdiction.District.Create("District 42");
var country = Jurisdiction.Country.Create("DE");
var unknown = Jurisdiction.Unknown.Instance;
// Comparing jurisdictions
var district42 = Jurisdiction.District.Create("DISTRICT 42");
logger.Information("district == district42: {IsEqual}", district == district42); // true
var district43 = Jurisdiction.District.Create("District 43");
logger.Information("district == district43: {IsEqual}", district == district43); // false
logger.Information("unknown == Jurisdiction.Unknown.Instance: {IsEqual}", unknown == Jurisdiction.Unknown.Instance); // true
// Validation examples
try
{
var invalidJuristiction = Jurisdiction.Country.Create("DEU"); // Throws ValidationException
}
catch (ValidationException ex)
{
logger.Information(ex.Message); // "ISO code must be exactly 2 characters long."
}
var description = district.Switch(
country: c => $"Country: {c}",
federalState: s => $"Federal state: {s}",
district: d => $"District: {d}",
unknown: _ => "Unknown"
);
logger.Information(description);
If the Jurisdiction
must be serialized as JSON then it requires a custom JSON converter.
The converter needs to know the type of the object to be serialized. This can be achieved by using a Smart Enum as a discriminator (JurisdictionJsonConverter).
This example demonstrates how to use discriminated unions to model a message processing pipeline with different states and transitions. It combines records with inheritance to create a rich state model that can be persisted using Entity Framework Core.
Entities
public class Message
{
public required Guid Id { get; init; }
public required List<MessageState> States { get; set; }
}
[Union]
public abstract partial record MessageState
{
public int Order { get; } // auto-incremented PK
public sealed record Initial : MessageState;
public sealed record Parsed(DateTime CreatedAt) : MessageState;
public sealed record Processed(DateTime CreatedAt) : MessageState;
public sealed record Error(string Message) : MessageState;
}
Entity Framework Core configurations
public class MessageEntityTypeConfiguration : IEntityTypeConfiguration<Message>
{
public void Configure(EntityTypeBuilder<Message> builder)
{
builder.ToTable("Messages");
builder.HasMany(m => m.States)
.WithOne()
.HasForeignKey("MessageId");
builder.Navigation(m => m.States).AutoInclude();
}
}
public class MessageStateEntityTypeConfiguration
: IEntityTypeConfiguration<MessageState>,
IEntityTypeConfiguration<MessageState.Parsed>,
IEntityTypeConfiguration<MessageState.Processed>
{
public void Configure(EntityTypeBuilder<MessageState> builder)
{
builder.ToTable("MessageStates");
builder.HasKey(s => s.Order);
builder.Property(s => s.Order).ValueGeneratedOnAdd(); // auto-increment
builder.Property<Guid>("MessageId"); // FK to the message table (as a shadow property)
builder
.HasDiscriminator<string>("Type")
.HasValue<MessageState.Initial>("Initial")
.HasValue<MessageState.Parsed>("Parsed")
.HasValue<MessageState.Processed>("Processed")
.HasValue<MessageState.Error>("Error");
}
public void Configure(EntityTypeBuilder<MessageState.Parsed> builder)
{
builder.Property(s => s.CreatedAt).HasColumnName("CreatedAt");
}
public void Configure(EntityTypeBuilder<MessageState.Processed> builder)
{
builder.Property(s => s.CreatedAt).HasColumnName("CreatedAt");
}
}
Database schema (SQL Server)
CREATE TABLE [Messages]
(
[Id] uniqueidentifier NOT NULL,
CONSTRAINT [PK_Messages] PRIMARY KEY ([Id])
);
CREATE TABLE [MessageStates]
(
[Order] int NOT NULL IDENTITY,
[MessageId] uniqueidentifier NOT NULL,
[Type] nvarchar(13) NOT NULL,
[Message] nvarchar(max) NULL,
[CreatedAt] datetime2 NULL,
CONSTRAINT [PK_MessageStates] PRIMARY KEY ([Order]),
CONSTRAINT [FK_MessageStates_Messages_MessageId] FOREIGN KEY ([MessageId]) REFERENCES [Messages] ([Id]) ON DELETE CASCADE
);
Usage
var message = new Message
{
Id = Guid.NewGuid(),
States = [new MessageState.Initial()]
};
ctx.Messages.Add(message);
await ctx.SaveChangesAsync();
ctx.ChangeTracker.Clear(); // remove message from the EF context
// Message states: [{"Order": 1, "$type": "Initial"}]
message = await ctx.Messages.SingleAsync();
logger.LogInformation("Message states: {@States}", message.States);
// Trasition to "Parsed" state
message.States.Add(new MessageState.Parsed(DateTime.Now));
await ctx.SaveChangesAsync();
ctx.ChangeTracker.Clear(); // remove message from the EF context
// Message states: [{"Order": 1, "$type": "Initial"}, {"CreatedAt": "2025-03-16T10:53:35.7020302", "Order": 2, "$type": "Parsed"}]
message = await ctx.Messages.SingleAsync();
logger.LogInformation("Message states: {@States}", message.States);
var currentState = message.States.OrderBy(s => s.Order).Last();
// Parsed (at 03/16/2025 10:52:35)
currentState.Switch(
initial: _ => logger.LogInformation("Initial state"),
parsed: s => logger.LogInformation("Parsed (at {ParsedAt})", s.CreatedAt),
processed: s => logger.LogInformation("Processed (at {ProcessedAt})", s.CreatedAt),
error: s => logger.LogInformation("Error: {Error}", s.Message)
);