Repository navigation
STJ: union vs. class asymmetry — missing JSON silently produces default(union) that crashes on serialize #128834
Description
Is there an existing issue for this?
- I have searched the existing issues
Description
When a JSON payload does not contain a value for a property of union type (either the key is absent or the entire body does not match the target shape), System.Text.Json silently constructs the containing record with default(union) for that property — same defaulting rule it applies to any other value type. No exception is thrown on read.
If the user then serializes that same instance back to JSON (e.g. a Minimal API endpoint that returns the parameter it received), the union converter throws:
System.Text.Json.JsonException: The union type 'TUnion' does not support null values. Path: $.Payload.
at System.Text.Json.ThrowHelper.ThrowJsonException_UnionDoesNotAcceptNull(Type unionType)
at System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver.UnionMetadataBuilder`1.<>c__DisplayClass8_0.<>b__0(TUnion union)
at System.Text.Json.Serialization.Converters.JsonUnionConverter`1.OnTryWrite(...)
The same dumb deserialize → serialize round-trip with an equivalent class record succeeds (all properties come back at their CLR defaults — 0, null, etc.). Unions are the only type where the silent-default rule on read produces an instance the serializer itself cannot write.
Reproducer
Union case (throws on write):
app.MapPost("/union/envelope", (UnionEnvelopeWithClassifier e) => e);
public record UnionEnvelopeWithClassifier(string CorrelationId, UnionIntStringWithClassifier Payload);
[JsonUnion(TypeClassifier = typeof(UnionIntStringClassifierFactory))]
public union UnionIntStringWithClassifier(int, string);POST /union/envelope
Content-Type: application/json
{"name":"Whiskers"}→ 500 (UnionDoesNotAcceptNull thrown at $.Payload during write).
Class case (silently round-trips):
app.MapPost("/weather", (Weather w) => w);
public class Weather
{
public int TemperatureC { get; set; }
public string? Summary { get; set; }
}POST /weather
Content-Type: application/json
{"random":"lol"}→ 200 {"temperatureC":0,"summary":null}.
Root cause
- STJ deserializes a containing record. For each constructor parameter or settable property whose JSON key is missing, STJ assigns the type's CLR default —
nullfor reference types,default(T)for value types. This is uniform across all types. - Unions compile to value types (structs) when any case is a value type, so a missing union property produces
default(union)— a struct whose internal "selected case" tag is zero, i.e. no case set. default(union).Valuereturnsnull(theValueproperty is typedobject?)- On serialization,
JsonUnionConverter<TUnion>.OnTryWriteasks the metadata callback for the case value, receivesnull, sees that no declared case isnull, and throws.
So the asymmetry is not in the deserializer — it is that JsonUnionConverter.OnTryWrite refuses to write a state that the deserializer is perfectly willing to construct.
Proposal
Change behavior to match class case which does not fail on serialization on "default" value
Workarounds available today
JsonSerializerOptions.RespectRequiredConstructorParameters = true(global, .NET 9+).[property: JsonRequired]on positional record parameters,[JsonRequired]on settable properties.- Declare the property as
TUnion?and treat absence asnull. - Run a custom validator (DataAnnotations, FluentValidation, MinimalApis.Extensions) before the handler.
.NET version
11 (union types preview).
Related #127299
Activity
dotnet-policy-service commentedon Jun 1, 2026
Tagging subscribers to this area: @dotnet/area-system-text-json
See info in area-owners.md if you want to be subscribed.
eiriktsarpalis commentedon Jun 1, 2026
For constructor parameters in particular, this a historical quirk that can be disabled via an opt-in setting. The serializer notably doesn't assign
defaultin property setters -- it simply doesn't assign anything if no corresponding property exists in the JSON payload. This suggests to me that the following scenario is broken:Incidentally, running this pattern match against the union
results in warning CS8655: The switch expression does not handle some null inputs so I presume we want to able to honor null inputs in that case as well.
Fix two union serialization defects: nullable case overload merging (d…
Fix two union serialization defects: nullable case overload merging (#…