Skip to content

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

Closed

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

Activity

  1. dotnet-policy-service commented on Jun 1, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-text-json
    See info in area-owners.md if you want to be subscribed.

  2. eiriktsarpalis commented on Jun 1, 2026

    @eiriktsarpalis
    Member

    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.

    For constructor parameters in particular, this a historical quirk that can be disabled via an opt-in setting. The serializer notably doesn't assign default in 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:

    JsonSerializer.Serialize(new Container());
    
    public class Container
    {
        public BoolOrInt Value { get; set; }
    }
    
    public union BoolOrInt(bool, int);

    Incidentally, running this pattern match against the union

    Type GetType(BoolOrInt value) => value switch
    {
        bool => typeof(bool),
        int => typeof(int),
    };

    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.

  3. added and removed
    untriagedNew issue has not been triaged by the area owner
    on Jun 1, 2026
  4. added this to the 11.0.0 milestone on Jun 1, 2026
  5. locked and limited conversation to collaborators on Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions