Skip to content

Commit 2ea38bb

Browse files
Round-trip default(struct union) ↔ JSON null
A `default(struct union)` has no selected case and its `Value` returns null. Previously the deconstructor threw `UnionDoesNotAcceptNull` in that state, so `JsonSerializer.Serialize(new Container())` crashed at the union property even when the container was newly constructed and the union was untouched. The C# compiler's CS8655 already treats this null state as part of every union's domain — a `value switch` on any union requires a `null` arm. Align serialization with that contract: * The reflection deconstructor returns `(null, null)` when `Value` is null and no nullable case exists (and dispatches through the nullable case when one is present). * The source-generated emitter always emits a `null =>` switch arm: a typed arm when a nullable case exists, or `null => ((global::System.Type?)null, (object?)null)` otherwise. * `JsonUnionConverter` already writes JSON null when the deconstructor reports `(null, null)`. For symmetry on the read side, `JsonUnionConverter.OnTryRead` now produces `default(TUnion)` instead of throwing when JSON null arrives for a union with no nullable case. This makes `default(union) → "null" → default(union)` a full round-trip. The constructor-delegate contract is unchanged — callers that invoke the delegate directly with `null` on a non-nullable union still see `UnionDoesNotAcceptNull`. Add round-trip tests for `NoNullableCaseUnion`, its container, and a new `IntOrBool` (`union IntOrBool(int, bool);`) that mirrors the exact repro shape from the issue. Fixes #128834 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5eeca28 commit 2ea38bb

5 files changed

Lines changed: 176 additions & 9 deletions

File tree

‎src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,14 @@ private static SourceText GenerateForUnion(ContextGenerationSpec contextSpec, Ty
731731
{
732732
writer.WriteLine($"null => (typeof({nullCase.CaseType.FullyQualifiedName}), (object?)null),");
733733
}
734+
else
735+
{
736+
// Always emit a null arm. C# pattern matching treats default(union)
737+
// as the null state (CS8655) and the union's Value property returns
738+
// null in that state. Routing to (null, null) signals
739+
// JsonUnionConverter to write JSON null instead of throwing.
740+
writer.WriteLine("null => ((global::System.Type?)null, (object?)null),");
741+
}
734742

735743
// unionCases is in topological most-derived-first order, so the first
736744
// matching arm always corresponds to the nearest declared case for any

‎src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert,
4343
Type? nullableCaseType = typeInfo.UnionNullableCaseType;
4444
if (nullableCaseType is null)
4545
{
46-
ThrowHelper.ThrowJsonException_UnionDoesNotAcceptNull(typeToConvert);
46+
// Round-trip symmetry with the serializer: default(union) writes
47+
// JSON null (deconstructor returns (null, null)), so JSON null must
48+
// read back as default(union). For value-type unions this is the
49+
// zero-init struct; for reference-type unions this is null.
50+
// See https://github.com/dotnet/runtime/issues/128834.
51+
value = default;
52+
return true;
4753
}
4854

4955
value = constructor(nullableCaseType, null);

‎src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Union.cs‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,15 @@ private static void PopulateUnionDeconstructor(
234234
object? value = valueAccessor(union);
235235
if (value is null)
236236
{
237-
if (nullableCase is null)
238-
{
239-
ThrowHelper.ThrowJsonException_UnionDoesNotAcceptNull(typeof(TUnion));
240-
}
241-
242-
return (nullableCase.CaseType, null);
237+
// Always succeed on null. Two scenarios converge here:
238+
// (1) default(struct union) has no case set and Value returns null.
239+
// (2) A nullable case was constructed with null (Foo((string?)null)).
240+
// When a nullable case exists, dispatch through it so the converter
241+
// calls the case's own converter for null. Otherwise return
242+
// (null, null) — JsonUnionConverter writes JSON null in that case.
243+
return nullableCase is not null
244+
? (nullableCase.CaseType, null)
245+
: (null, null);
243246
}
244247

245248
Type runtimeType = value.GetType();

‎src/libraries/System.Text.Json/tests/Common/UnionTests.cs‎

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,11 +465,19 @@ public void Deconstructor_NullUnionInstance_ReturnsNullCaseTypeAndValue()
465465
}
466466

467467
[Fact]
468-
public void Deconstructor_UnionWithNullValue_NoNullableCase_Throws()
468+
public void Deconstructor_UnionWithNullValue_NoNullableCase_ReturnsNullPair()
469469
{
470+
// The deconstructor must succeed when a case payload is null even on a union
471+
// with no nullable case (e.g. HierarchyUnion(Animal) where Animal is declared
472+
// non-nullable but the user passed null!). Returning (null, null) signals
473+
// JsonUnionConverter to write JSON null — matching the class-record behavior
474+
// described in https://github.com/dotnet/runtime/issues/128834.
470475
JsonTypeInfo<HierarchyUnion> typeInfo = Serializer.GetTypeInfo<HierarchyUnion>();
471476

472-
Assert.ThrowsAny<Exception>(() => typeInfo.UnionDeconstructor!(new HierarchyUnion((Animal)null!)));
477+
(Type? caseType, object? caseValue) = typeInfo.UnionDeconstructor!(new HierarchyUnion((Animal)null!));
478+
479+
Assert.Null(caseType);
480+
Assert.Null(caseValue);
473481
}
474482

475483
[Fact]
@@ -536,6 +544,19 @@ public void Constructor_NullValue_NullableCaseExists_BuildsViaNullableCtor()
536544

537545
public union NoNullableCaseUnion(int);
538546

547+
public class NoNullableCaseUnionContainer
548+
{
549+
public NoNullableCaseUnion Union { get; set; }
550+
public int After { get; set; }
551+
}
552+
553+
public union IntOrBool(int, bool);
554+
555+
public class IntOrBoolContainer
556+
{
557+
public IntOrBool Value { get; set; }
558+
}
559+
539560
[Fact]
540561
public void Constructor_NullValue_NoNullableCase_Throws()
541562
{
@@ -546,6 +567,129 @@ public void Constructor_NullValue_NoNullableCase_Throws()
546567
Assert.ThrowsAny<Exception>(() => typeInfo.UnionConstructor!(typeof(int), null));
547568
}
548569

570+
[Fact]
571+
public void Deconstructor_DefaultValueOfStructUnion_NoNullableCase_ReturnsNullPair()
572+
{
573+
JsonTypeInfo<NoNullableCaseUnion> typeInfo = Serializer.GetTypeInfo<NoNullableCaseUnion>();
574+
Assert.NotNull(typeInfo.UnionDeconstructor);
575+
576+
(Type? caseType, object? caseValue) = typeInfo.UnionDeconstructor!(default);
577+
578+
Assert.Null(caseType);
579+
Assert.Null(caseValue);
580+
}
581+
582+
[Fact]
583+
public async Task RoundTrip_DefaultValueOfStructUnion_NoNullableCase()
584+
{
585+
string json = await Serializer.SerializeWrapper(default(NoNullableCaseUnion));
586+
Assert.Equal("null", json);
587+
588+
// Symmetry: JSON null must deserialize back to default(NoNullableCaseUnion),
589+
// which deconstructs to (null, null) — the same shape that produced the JSON.
590+
NoNullableCaseUnion roundtripped = await Serializer.DeserializeWrapper<NoNullableCaseUnion>(json);
591+
JsonTypeInfo<NoNullableCaseUnion> typeInfo = Serializer.GetTypeInfo<NoNullableCaseUnion>();
592+
(Type? caseType, object? caseValue) = typeInfo.UnionDeconstructor!(roundtripped);
593+
Assert.Null(caseType);
594+
Assert.Null(caseValue);
595+
}
596+
597+
[Fact]
598+
public async Task RoundTrip_ContainerWithDefaultStructUnionProperty()
599+
{
600+
string json = await Serializer.SerializeWrapper(new NoNullableCaseUnionContainer { After = 7 });
601+
JsonTestHelper.AssertJsonEqual("""{"Union":null,"After":7}""", json);
602+
603+
NoNullableCaseUnionContainer? roundtripped =
604+
await Serializer.DeserializeWrapper<NoNullableCaseUnionContainer>(json);
605+
Assert.NotNull(roundtripped);
606+
Assert.Equal(7, roundtripped.After);
607+
608+
JsonTypeInfo<NoNullableCaseUnion> typeInfo = Serializer.GetTypeInfo<NoNullableCaseUnion>();
609+
(Type? caseType, object? caseValue) = typeInfo.UnionDeconstructor!(roundtripped.Union);
610+
Assert.Null(caseType);
611+
Assert.Null(caseValue);
612+
}
613+
614+
[Fact]
615+
public async Task RoundTrip_ContainerWithMissingUnionProperty_DoesNotThrowOnReserialize()
616+
{
617+
NoNullableCaseUnionContainer? container =
618+
await Serializer.DeserializeWrapper<NoNullableCaseUnionContainer>("""{"After":7}""");
619+
Assert.NotNull(container);
620+
Assert.Equal(7, container.After);
621+
622+
// Step 2: serialize the same instance back. The union property must come
623+
// back as JSON null — the round-trip must not throw.
624+
string json = await Serializer.SerializeWrapper(container);
625+
JsonTestHelper.AssertJsonEqual("""{"Union":null,"After":7}""", json);
626+
}
627+
628+
[Fact]
629+
public void Deconstructor_DefaultValueOfIntOrBool_ReturnsNullPair()
630+
{
631+
JsonTypeInfo<IntOrBool> typeInfo = Serializer.GetTypeInfo<IntOrBool>();
632+
Assert.NotNull(typeInfo.UnionDeconstructor);
633+
634+
(Type? caseType, object? caseValue) = typeInfo.UnionDeconstructor!(default);
635+
636+
Assert.Null(caseType);
637+
Assert.Null(caseValue);
638+
}
639+
640+
[Fact]
641+
public async Task RoundTrip_DefaultValueOfIntOrBool()
642+
{
643+
string json = await Serializer.SerializeWrapper(default(IntOrBool));
644+
Assert.Equal("null", json);
645+
646+
// Symmetry: JSON null must deserialize back to default(IntOrBool).
647+
IntOrBool roundtripped = await Serializer.DeserializeWrapper<IntOrBool>(json);
648+
JsonTypeInfo<IntOrBool> typeInfo = Serializer.GetTypeInfo<IntOrBool>();
649+
(Type? caseType, object? caseValue) = typeInfo.UnionDeconstructor!(roundtripped);
650+
Assert.Null(caseType);
651+
Assert.Null(caseValue);
652+
}
653+
654+
[Fact]
655+
public async Task RoundTrip_NewContainerOfIntOrBool()
656+
{
657+
// Exact translation of `JsonSerializer.Serialize(new Container())` from the
658+
// issue comment: the container holds a non-nullable IntOrBool whose default
659+
// value has no case selected. Before the fix this threw
660+
// UnionDoesNotAcceptNull at $.Value. Now it must succeed and emit
661+
// {"Value":null} — and the resulting JSON must round-trip back to a
662+
// semantically identical container.
663+
string json = await Serializer.SerializeWrapper(new IntOrBoolContainer());
664+
JsonTestHelper.AssertJsonEqual("""{"Value":null}""", json);
665+
666+
IntOrBoolContainer? roundtripped =
667+
await Serializer.DeserializeWrapper<IntOrBoolContainer>(json);
668+
Assert.NotNull(roundtripped);
669+
670+
JsonTypeInfo<IntOrBool> typeInfo = Serializer.GetTypeInfo<IntOrBool>();
671+
(Type? caseType, object? caseValue) = typeInfo.UnionDeconstructor!(roundtripped.Value);
672+
Assert.Null(caseType);
673+
Assert.Null(caseValue);
674+
}
675+
676+
[Fact]
677+
public void Deconstructor_DefaultValueOfIntOrBool_NonDefaultPayload_DispatchesNormally()
678+
{
679+
// Regression guard: the always-null arm must not shadow the per-case arms.
680+
// Construct IntOrBool with a non-null value and verify the deconstructor
681+
// still reports the right case type.
682+
JsonTypeInfo<IntOrBool> typeInfo = Serializer.GetTypeInfo<IntOrBool>();
683+
684+
(Type? intCaseType, object? intCaseValue) = typeInfo.UnionDeconstructor!(new IntOrBool(42));
685+
Assert.Equal(typeof(int), intCaseType);
686+
Assert.Equal(42, intCaseValue);
687+
688+
(Type? boolCaseType, object? boolCaseValue) = typeInfo.UnionDeconstructor!(new IntOrBool(true));
689+
Assert.Equal(typeof(bool), boolCaseType);
690+
Assert.Equal(true, boolCaseValue);
691+
}
692+
549693
[Fact]
550694
public void Constructor_NullValueAndNullCaseType_RoutesToNullableCase()
551695
{

‎src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public UnionTests_Metadata()
2121
[JsonSerializable(typeof(HierarchyUnion))]
2222
[JsonSerializable(typeof(NullableCaseUnion))]
2323
[JsonSerializable(typeof(NoNullableCaseUnion))]
24+
[JsonSerializable(typeof(NoNullableCaseUnionContainer))]
25+
[JsonSerializable(typeof(IntOrBool))]
26+
[JsonSerializable(typeof(IntOrBoolContainer))]
2427
[JsonSerializable(typeof(ValueTypeNullablePairUnion))]
2528
[JsonSerializable(typeof(SingleNullableValueTypeUnion))]
2629
[JsonSerializable(typeof(SingleObjectUnion))]
@@ -78,6 +81,9 @@ public UnionTests_Default()
7881
[JsonSerializable(typeof(HierarchyUnion))]
7982
[JsonSerializable(typeof(NullableCaseUnion))]
8083
[JsonSerializable(typeof(NoNullableCaseUnion))]
84+
[JsonSerializable(typeof(NoNullableCaseUnionContainer))]
85+
[JsonSerializable(typeof(IntOrBool))]
86+
[JsonSerializable(typeof(IntOrBoolContainer))]
8187
[JsonSerializable(typeof(ValueTypeNullablePairUnion))]
8288
[JsonSerializable(typeof(SingleNullableValueTypeUnion))]
8389
[JsonSerializable(typeof(SingleObjectUnion))]

0 commit comments

Comments
 (0)