Skip to content

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

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

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