Is there an existing issue for this?
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 —
null for 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).Value returns null (the Value property is typed object?)
- On serialization,
JsonUnionConverter<TUnion>.OnTryWrite asks the metadata callback for the case value, receives null, sees that no declared case is null, 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 as null.
- Run a custom validator (DataAnnotations, FluentValidation, MinimalApis.Extensions) before the handler.
.NET version
11 (union types preview).
Related #127299
Is there an existing issue for this?
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.Jsonsilently constructs the containing record withdefault(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:
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):
→ 500 (
UnionDoesNotAcceptNullthrown at$.Payloadduring write).Class case (silently round-trips):
→ 200
{"temperatureC":0,"summary":null}.Root cause
nullfor reference types,default(T)for value types. This is uniform across all types.default(union)— a struct whose internal "selected case" tag is zero, i.e. no case set.default(union).Valuereturnsnull(theValueproperty is typedobject?)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.OnTryWriterefuses 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.TUnion?and treat absence asnull..NET version
11 (union types preview).
Related #127299