Version 8.x.x 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 ad hoc unions don't have built-in support for serialization or ASP.NET Core model binding like regular unions with [JsonDerivedType]
or custom converters, you can achieve this by implementing specific interfaces from the Thinktecture.Runtime.Extensions
library. This approach treats the union like a simple value object that serializes to/from a single primitive type (e.g., string
).
Implementation Steps:
-
Add
[ObjectFactory<TValue>]
attribute: Mark the union withObjectFactoryAttribute
and specifyUseForSerialization = SerializationFrameworks.All
(or specific frameworks).
Source Generator will add to 2 interfaces that have to be implemented. If the value is of typestring
then the Source Generator will implementIParsable<TUnion>
.-
Implement
IConvertible<TValue>
: Define how the union converts to a primitive type for serialization. -
Implement
IObjectFactory<TUnion, TValue, TValidationError>
: Define how the union is created from the primitive type during deserialization, including validation.
-
Implement
-
Add
[JsonConverter]
(Optional): Use[JsonConverter(typeof(ThinktectureJsonConverterFactory))]
forSystem.Text.Json
integration. Alternatively, registerThinktectureJsonConverterFactory
globally inJsonSerializerOptions
. Similar converters/resolvers exist forNewtonsoft.Json
andMessagePack
.
Example: TextOrNumber
This union can hold either a string
or an int
and serializes to/from a string format like "Text|value"
or "Number|value"
.
[Union<string, int>(T1Name = "Text",
T2Name = "Number")]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)] // Mark for serialization as string
[JsonConverter(typeof(ThinktectureJsonConverterFactory))] // [Optional] Alternatively, register ThinktectureJsonConverterFactory with JsonSerializerOptions
public partial class TextOrNumber
{
// For serialization (IConvertible<string>)
public string ToValue()
{
// Custom logic to convert union to string
return Switch(text: t => $"Text|{t}",
number: n => $"Number|{n}");
}
// For deserialization/validation (IObjectFactory<...>)
// Also used by IParsable<T>.TryParse
public static ValidationError? Validate(string? value, IFormatProvider? provider, out TextOrNumber? item)
{
if (String.IsNullOrWhiteSpace(value))
{
item = null;
return null;
}
if (value.StartsWith("Text|", StringComparison.OrdinalIgnoreCase))
{
item = value.Substring(5); // Implicit conversion from string
return null;
}
if (value.StartsWith("Number|", StringComparison.OrdinalIgnoreCase))
{
if (Int32.TryParse(value.Substring(7), out var number))
{
item = number; // Implicit conversion from int
return null;
}
item = null;
return new ValidationError("Invalid number format");
}
return new ValidationError("Invalid format. Expected 'Text|...' or 'Number|...'.");
}
}
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.
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)
);