Version 8.x.x Discriminated Unions - PawelGerr/Thinktecture.Runtime.Extensions GitHub Wiki

Introduction

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

Requirements

  • C# 11 (or higher) for generated code
  • SDK 8.0.400 (or higher) for building projects

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

Ad hoc unions

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

What you implement

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;

What is implemented for you

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;

Pattern Matching with Switch/Map

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");

Customizing

Renaming of properties

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;

Definition of nullable reference types

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?

Default string comparison

Configure string comparison behavior:

[Union<string, int>(DefaultStringComparison = StringComparison.Ordinal)]
public partial class TextOrNumber;

Skip implementation of ToString

Disable ToString generation:

[Union<string, int>(SkipToString = true)]
public partial class TextOrNumber;

Constructor access modifier

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;

Skip implementation of implicit casts

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;
    }
}

Pattern matching with partial coverage

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"
);

Serialization and Model Binding

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:

  1. Add [ObjectFactory<TValue>] attribute: Mark the union with ObjectFactoryAttribute and specify UseForSerialization = SerializationFrameworks.All (or specific frameworks).
    Source Generator will add to 2 interfaces that have to be implemented. If the value is of type string then the Source Generator will implement IParsable<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.
  2. Add [JsonConverter] (Optional): Use [JsonConverter(typeof(ThinktectureJsonConverterFactory))] for System.Text.Json integration. Alternatively, register ThinktectureJsonConverterFactory globally in JsonSerializerOptions. Similar converters/resolvers exist for Newtonsoft.Json and MessagePack.

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

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).

Basic implementation

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;
}

Advanced scenarios

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}"
);

Integration with value objects

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");

Real-world use cases and ideas

Here are some examples I used in the past to show the developers the benefits of discriminated unions.

Partially Known Date

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"

Jurisdiction

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).

Message Processing State Management

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)
);
⚠️ **GitHub.com Fallback** ⚠️