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 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 aValidationError?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");
}
}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:
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.
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.
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.
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 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$typediscriminator. 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);Regular unions are polymorphic types, and each serialization framework handles polymorphism differently:
-
System.Text.Json:
[JsonDerivedType]with type discriminators (shown above) -
Newtonsoft.Json:
TypeNameHandlingwith$typemetadata -
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:
TypeNameHandlingcan 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.
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)
);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.