Discriminated Unions Framework Integration - PawelGerr/Thinktecture.Runtime.Extensions GitHub Wiki

This page covers serialization, model binding, and persistence for both ad hoc and regular unions.

Ad Hoc Union Framework Integration

Object Factory Setup

Ad hoc unions cannot be serialized as polymorphic JSON because they lack a type discriminator. The [ObjectFactory<T>] attribute enables custom single-value serialization by defining how the union is converted to and from another type (most commonly a string). This custom representation can serve JSON serialization, ASP.NET Core model binding, and Entity Framework Core persistence simultaneously.

The attribute requires two user-defined methods on the union type:

  • ToValue() -- converts the union to the target type (e.g., string) for serialization.
  • Validate() -- converts back from the target type for deserialization, returning a ValidationError? to signal invalid input.

For full details on ObjectFactoryAttribute<T> properties (UseForSerialization, UseForModelBinding, UseWithEntityFramework, HasCorrespondingConstructor), multiple object factories, and zero-allocation JSON with ReadOnlySpan<char>, see the Object Factories page.

[Union<string, int>(T1Name = "Text", T2Name = "Number")]
[ObjectFactory<string>(
   UseForSerialization = SerializationFrameworks.All,
   UseForModelBinding = true,
   UseWithEntityFramework = true)]
public partial class TextOrNumber
{
   // Conversion to string for serialization
   public string ToValue()
   {
      return Switch(text: t => $"Text|{t}",
                    number: n => $"Number|{n}");
   }

   // Conversion from string for deserialization
   public static ValidationError? Validate(
      string? value, IFormatProvider? provider, out TextOrNumber? item)
   {
      if (value.StartsWith("Text|", StringComparison.OrdinalIgnoreCase))
      {
         item = value.Substring(5);
         return null;
      }

      if (value.StartsWith("Number|", StringComparison.OrdinalIgnoreCase))
      {
         if (Int32.TryParse(value.Substring(7), out var number))
         {
            item = number;
            return null;
         }

         item = null;
         return new ValidationError("Invalid number format");
      }

      item = null;
      return new ValidationError("Invalid format");
   }
}

JSON Serialization

When UseForSerialization is set on the [ObjectFactory<T>] attribute, the source generator produces serialization converters for the specified frameworks. The easiest way to enable JSON serialization is to make the corresponding integration package a dependency (direct or transitive) of the project containing the union type:

  • System.Text.Json: Thinktecture.Runtime.Extensions.Json
  • Newtonsoft.Json: Thinktecture.Runtime.Extensions.Newtonsoft.Json
  • MessagePack: Thinktecture.Runtime.Extensions.MessagePack

Alternatively, you can register a JSON converter directly with the serializer settings (ThinktectureJsonConverterFactory for System.Text.Json, ThinktectureNewtonsoftJsonConverterFactory for Newtonsoft.Json). For all setup options and zero-allocation JSON deserialization on .NET 9+ using ReadOnlySpan<char>, see the Object Factories page.

Multi-Format Serialization (Ad Hoc)

Ad hoc unions already require [ObjectFactory<T>] for serialization, so multi-format consistency is straightforward -- set UseForSerialization = SerializationFrameworks.All and all three frameworks use the same Validate and ToValue methods. The TextOrNumber example above already demonstrates this with SerializationFrameworks.All.

Reference all three integration packages from the project containing your union type:

  • Thinktecture.Runtime.Extensions.Json (System.Text.Json)
  • Thinktecture.Runtime.Extensions.Newtonsoft.Json (Newtonsoft.Json)
  • Thinktecture.Runtime.Extensions.MessagePack (MessagePack)

Use the SerializationFrameworks enum to target individual frameworks when needed:

Value Description
None No serialization integration
All All supported frameworks
Json System.Text.Json + Newtonsoft.Json
SystemTextJson System.Text.Json only
NewtonsoftJson Newtonsoft.Json only
MessagePack MessagePack only

Multiple [ObjectFactory] attributes can target different frameworks with non-overlapping UseForSerialization values. See Object Factories -- Multiple Object Factories for details.

For walkthroughs with other type categories, see Value Objects -- Multi-Format Serialization and Smart Enums -- Multi-Format Serialization.

Entity Framework Core

Ad hoc unions can be persisted using ObjectFactoryAttribute<T> -- EF Core's ValueConverter bridges between the union type and its primitive representation. Install the version-matched package for your EF Core version: Thinktecture.Runtime.Extensions.EntityFrameworkCore8, 9, or 10.

The entity containing the union property:

public class Document
{
    public required int Id { get; init; }
    public required TextOrNumber Content { get; set; }
}

Register the value converters -- the recommended approach is globally via DbContextOptionsBuilder:

services.AddDbContext<DocumentDbContext>(builder => builder
    .UseSqlServer(connectionString)
    .UseThinktectureValueConverters());

Other registration levels (model-level, entity-level, property-level) are also available. See Object Factories -- UseWithEntityFramework for all options and configuration details.

ASP.NET Core

The [ObjectFactory<string>] attribute causes the source generator to implement IParsable<T> on the union type. With that in place, the union can be used directly in controller actions. For best results with ASP.NET Core MVC (controllers), register the ThinktectureModelBinderProvider from the Thinktecture.Runtime.Extensions.AspNetCore package:

services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new ThinktectureModelBinderProvider());
});

For full details on model binding configuration, see Object Factories -- UseForModelBinding.

Regular Union Framework Integration

JSON Serialization with JsonDerivedType

Regular unions represent polymorphic types. For System.Text.Json, apply [JsonDerivedType] attributes to the base union class for each nested case. This adds a type discriminator ($type) to the JSON.

Newtonsoft.Json: Regular unions can also be serialized with Newtonsoft.Json using TypeNameHandling, which natively adds a $type discriminator. No additional integration package is required.

[JsonDerivedType(typeof(PartiallyKnownDate.YearOnly), "Year")]
[JsonDerivedType(typeof(PartiallyKnownDate.YearMonth), "YearMonth")]
[JsonDerivedType(typeof(PartiallyKnownDate.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);
}

var date = new PartiallyKnownDate.Date(2024, 3, 15);

// JSON: {"$type":"Date","Year":2024,"Month":3,"Day":15}
string json = JsonSerializer.Serialize<PartiallyKnownDate>(date);
PartiallyKnownDate? deserialized = JsonSerializer.Deserialize<PartiallyKnownDate>(json);

Multi-Format Serialization (Regular)

Regular unions are polymorphic types, and each serialization framework handles polymorphism differently:

  • System.Text.Json: [JsonDerivedType] with type discriminators (shown above)
  • Newtonsoft.Json: TypeNameHandling with $type metadata
  • MessagePack: MessagePack-CSharp has its own [Union] attribute -- this library does not integrate with it

There are two approaches for multi-format serialization of regular unions.

Approach 1: Per-framework polymorphism

Each framework uses its native polymorphism mechanism. This preserves full property-level structure but produces different wire formats per framework.

System.Text.Json is already covered above. For Newtonsoft.Json, use TypeNameHandling:

var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.Auto
};

var date = new PartiallyKnownDate.Date(2024, 3, 15);

// JSON: {"$type":"Date","Year":2024,"Month":3,"Day":15}
string json = JsonConvert.SerializeObject(date, settings);
PartiallyKnownDate? deserialized = JsonConvert.DeserializeObject<PartiallyKnownDate>(json, settings);

Security note: TypeNameHandling can be a deserialization vulnerability if not restricted.

For MessagePack, set up formatters for each derived type using MessagePack-CSharp's own polymorphism support (e.g., [MessagePackObject] on each case type with a custom resolver).

Approach 2: [ObjectFactory<string>] (recommended for cross-framework consistency)

Apply [ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)] to the base union type. This converts the entire union to and from a single string, giving all three frameworks the same wire format.

[Union]
[ObjectFactory<string>(
   UseForSerialization = SerializationFrameworks.All,
   UseForModelBinding = true,
   UseWithEntityFramework = true)]
public abstract partial record PartiallyKnownDateSerializable
{
   public int Year { get; }

   private PartiallyKnownDateSerializable(int year)
   {
      Year = year;
   }

   public sealed record YearOnly(int Year) : PartiallyKnownDateSerializable(Year);
   public sealed record YearMonth(int Year, int Month) : PartiallyKnownDateSerializable(Year);
   public sealed record Date(int Year, int Month, int Day) : PartiallyKnownDateSerializable(Year);

   public static ValidationError? Validate(
      string? value, IFormatProvider? provider, out PartiallyKnownDateSerializable? item)
   {
      if (value is null)
      {
         item = null;
         return null;
      }

      var parts = value.Split('-');

      item = parts.Length switch
      {
         1 => new YearOnly(int.Parse(parts[0], provider)),
         2 => new YearMonth(int.Parse(parts[0], provider), int.Parse(parts[1], provider)),
         3 => new Date(int.Parse(parts[0], provider), int.Parse(parts[1], provider),
                       int.Parse(parts[2], provider)),
         _ => null
      };

      return item is null
         ? new ValidationError("Invalid date format. Expected 'YYYY', 'YYYY-MM', or 'YYYY-MM-DD'.")
         : null;
   }

   public string ToValue()
   {
      return Switch(
         yearOnly: y => y.Year.ToString("D4"),
         yearMonth: ym => $"{ym.Year:D4}-{ym.Month:D2}",
         date: d => $"{d.Year:D4}-{d.Month:D2}-{d.Day:D2}"
      );
   }
}

All three frameworks serialize Date(2024, 3, 15) as "2024-03-15" and deserialize it using the same Validate method. Reference all three integration packages to enable this:

  • Thinktecture.Runtime.Extensions.Json (System.Text.Json)
  • Thinktecture.Runtime.Extensions.Newtonsoft.Json (Newtonsoft.Json)
  • Thinktecture.Runtime.Extensions.MessagePack (MessagePack)

Trade-off summary:

Approach Pros Cons
Per-framework polymorphism Full property visibility, native per framework Different wire formats, MessagePack needs its own setup
[ObjectFactory<string>] Same format everywhere, single pair of methods Loses property-level structure, custom parsing required

For full details on ObjectFactory configuration, see Object Factories. For the Value Objects equivalent, see Value Objects -- Multi-Format Serialization. For the Smart Enums equivalent, see Smart Enums -- Multi-Format Serialization.

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.

The following 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)
);

ASP.NET Core

Regular unions are polymorphic types that are typically exchanged as JSON in request and response bodies, using [JsonDerivedType] as shown above. When model binding from a single value (e.g., a route or query parameter) is required, the [ObjectFactory<string>] approach can be used -- the same technique described in the ad hoc union section applies to regular unions as well. See Object Factories -- UseForModelBinding for full details.

⚠️ **GitHub.com Fallback** ⚠️