-
Type:
Improvement
-
Resolution: Unresolved
-
Priority:
Major - P3
-
None
-
Affects Version/s: 3.10.0
-
Component/s: Serialization
-
None
-
None
-
Dotnet Drivers
-
None
-
None
-
None
-
None
-
None
-
None
C# 15 introduces the new union type. It ships with .NET 11 in November 2026, and unions already have full support in System.Text.Json as of .NET 11 Preview 6. The MongoDB C# driver currently has no awareness of the union pattern, so serializing or deserializing a union-typed field throws during class-map construction. I think the best way to support this would be to implement it so that it behaves the same way as System.Text.Json, so that anyone transitioning to MongoDB can understand how unions map under the hood.
Background: what C# 15 union types are
A union is declared with the union keyword and represents a value that is exactly one of a closed set of case types:
public record class Cat(string Name, bool IsIndoor); public record class Dog(string Name, string Breed); public record class Bird(string Name, bool CanFly); public union Pet(Cat, Dog, Bird);
Per the language proposal, a union type is any type carrying System.Runtime.CompilerServices.UnionAttribute. The runtime ships UnionAttribute and IUnion starting in .NET 11 Preview 5. The parts relevant to a serializer are:
- A Value property of type object? that holds the active case value (or null). The language guarantees Value is always null or an instance of one of the case types.
- Creation members (constructors or static Create factories, one per case type) that the compiler uses for the implicit conversions.
- An optional non-boxing access pattern (HasValue plus TryGetValue(out TCase) per case) that a serializer can use to read the active case in a strongly typed way without boxing.
So a serializer can discover the case set, read the active case via Value or TryGetValue, and reconstruct the union via the matching creation member.
Current behavior (repro)
Using driver 3.10.0 with a union type as the collection document type:
using MongoDB.Driver; var mongoClient = new MongoClient("mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000"); var database = mongoClient.GetDatabase("test"); var collection = database.GetCollection<Pet>("pets"); await collection.InsertOneAsync(new Cat("Whiskers", IsIndoor: true)); await collection.InsertOneAsync(new Dog("Fido", Breed: "Labrador")); await collection.InsertOneAsync(new Bird("Tweety", CanFly: true)); Console.WriteLine("Inserted 3 pets."); var allPets = await collection.Find(_ => true).ToListAsync(); foreach (var pet in allPets) { Console.WriteLine($"Read back: {pet}"); } public union Pet(Cat, Dog, Bird); record Cat(string Name, bool IsIndoor); record Dog(string Name, string Breed); record Bird(string Name, bool CanFly);
The driver falls back to BsonClassMapSerializer and treats Pet as an ordinary POCO. It finds the union's single-argument creation member but no matching configured properties, and throws:
Unhandled exception. MongoDB.Bson.BsonSerializationException: Creator map for class Pet has 1 arguments, but none are configured. at MongoDB.Bson.Serialization.BsonCreatorMap.Freeze() at MongoDB.Bson.Serialization.BsonClassMap.Freeze() at MongoDB.Bson.Serialization.BsonClassMap.LookupClassMap(Type classType) at MongoDB.Bson.Serialization.BsonClassMapSerializationProvider.GetSerializer(Type type, IBsonSerializerRegistry serializerRegistry) at MongoDB.Bson.Serialization.BsonSerializerRegistry.CreateSerializer(Type type) at System.Collections.Concurrent.ConcurrentDictionary`2.GetOrAdd(TKey key, Func`2 valueFactory) at MongoDB.Bson.Serialization.BsonSerializerRegistry.GetSerializer(Type type) at MongoDB.Bson.Serialization.BsonSerializerRegistry.GetSerializer[T]() at MongoDB.Driver.MongoCollectionImpl`1..ctor(IMongoDatabase database, CollectionNamespace collectionNamespace, MongoCollectionSettings settings, IClusterInternal cluster, IOperationExecutor operationExecutor) at MongoDB.Driver.MongoDatabase.GetCollection[TDocument](String name, MongoCollectionSettings settings) at Program.<Main>$(String[] args) in C:\dev\kevbite\MongoUnion\Program.cs:line 5 at Program.<Main>(String[] args)Process finished with exit code -532,462,766.
{{}}
The failure happens at GetCollection<Pet> time (class-map lookup), before any I/O, so unions are effectively unusable rather than merely mis-serialized.
How we think this would best be supported
We think the driver should recognize a type marked with UnionAttribute or implementing IUnion and provide a dedicated union serializer that:
- Serializes a union value as the active case's value (read via Value or TryGetValue).
- Deserializes back into the correct case type and wraps it in the union via the matching creation member.
- Supports case types that are complex types (records, as above), value types, and nullable value types.
- Round-trips exhaustively, so every declared case type is handled and a null Value is preserved.
Why align with System.Text.Json
System.Text.Json added union serialization in .NET 11 Preview 6 (announcement). In particular, STJ:
- Recognizes a union through a new JsonTypeInfoKind.Union contract kind (both reflection and source generation).
- Serializes the active case's value directly. For example, a union of int and string round-trips as 42 or "hello", with no wrapper.
- Selects the case on read by inspecting the token type in O(1) with no buffering, and treats unions whose cases share a JSON token as ambiguous (throwing when the contract is configured, with the source generator emitting a diagnostic).
- Exposes configuration via JsonUnionAttribute, JsonUnionCaseInfo, and JsonTypeClassifier / JsonSerializerOptions.TypeClassifiers for customizing case discovery, discriminator naming, and case metadata.
STJ is the reference implementation most .NET developers will meet unions through first, so matching its model as closely as the BSON type system allows keeps the mapping familiar and predictable. Specifically, we think it would be best to have:
- The same representation shape. A union of scalar cases would store the raw scalar, and a union of object cases would store the case's document. This keeps documents intuitive and matches what a developer already sees from STJ.
- The same discriminator strategy. For object cases where the case type can't be inferred from the BSON shape alone, it would be good to use a discriminator whose naming and behavior parallels STJ's classifier model rather than a driver-specific scheme. The driver already has _t / BsonDiscriminator machinery, so ideally that could be reconciled with STJ's approach so the on-disk contract is predictable.
- An analogous configuration surface. BSON equivalents (attributes or registration hooks) to STJ's JsonUnionAttribute and type classifier, so case discovery and discriminator naming can be customized.
The goal is that a developer who has serialized a union with STJ can look at the MongoDB document and immediately understand how the union mapped under the hood.
References
- C# 15 union types (What's new): https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15#union-types
- Union types feature specification: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/unions
- .NET 11 Preview 6, STJ union serialization: https://devblogs.microsoft.com/dotnet/dotnet-11-preview-6/
A note on the discriminator
The .NET 11 Preview 6 notes confirm the STJ API surface (JsonUnionAttribute, JsonUnionCaseInfo, JsonTypeClassifier, JsonTypeInfoKind.Union) and the scalar round-trip shape, but they don't publish an example of the exact discriminator JSON for object-typed cases. That's why the discriminator point above is framed as "align with STJ's model" rather than a literal $type format. The tracking issue is the authoritative source for the final design.