Skip to content

STJ: union vs. class asymmetry — missing JSON silently produces default(union) that crashes on serialize #128834

@DeagleGross

Description

@DeagleGross

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

  1. 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.
  2. 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.
  3. default(union).Value returns null (the Value property is typed object?)
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions