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 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 type T for ASP.NET Core Model Binding.
  • UseWithEntityFramework = true: This enables the unions to use the type T for persistence with Entity Framework Core.
[ObjectFactory<string>(
   UseForSerialization = SerializationFrameworks.All,
   UseForModelBinding = true,
   UseWithEntityFramework = true)]
public partial class TextOrNumber
{
    ...
}

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** ⚠️