Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Jan 3, 2026

Description

JsonSerializer.Serialize was producing invalid JSON when serializing objects with [JsonExtensionData] properties of type JsonObject. The output was missing the property name for extension data entries:

var mix = new Mix { Id = 1, Extra = new JsonObject { ["nested"] = true } };
JsonSerializer.Serialize(mix);
// Before: {"Id":1,{"nested":true}}  ← invalid JSON
// After:  {"Id":1,"nested":true}    ← correct

Root cause: TryWriteDataExtensionProperty in JsonConverterOfT.cs called TryWrite for JsonObject, which writes the full object including braces. Extension data should write only the contents (key-value pairs) without wrapping braces.

Fix: Added a new internal WriteContentsTo method to JsonObject that writes properties without wrapping braces, handling both dictionary-backed and JsonElement-backed cases with proper optimization. Updated TryWriteDataExtensionProperty to call this method, avoiding code duplication and maintaining consistency with how dictionary extension data is handled.

Customer Impact

Serialization of any class with [JsonExtensionData] of type JsonObject produces malformed JSON that cannot be parsed.

Regression

No. Reproducible from .NET 6 through .NET 8+. Deserialization works correctly; only serialization was affected.

Testing

  • Added 4 targeted tests covering: valid JSON output, round-trip, empty JsonObject, and null JsonObject
  • All 49,808 existing System.Text.Json.Tests pass
  • All 7,626 source generation tests pass
  • Registered new test class in source generator contexts (ExtensionDataTestsContext_Metadata and ExtensionDataTestsContext_Default)

Risk

Low. Change is minimal and isolated to the JsonObject branch of extension data serialization. Dictionary extension data path is unchanged.

Package authoring no longer needed in .NET 9

IMPORTANT: Starting with .NET 9, you no longer need to edit a NuGet package's csproj to enable building and bump the version.
Keep in mind that we still need package authoring in .NET 8 and older versions.

Original prompt

This section details on the original issue you should resolve

<issue_title>JsonSerializer.Serialize produces invalid JSON for [JsonExtensionData] property</issue_title>
<issue_description>### Description

When serializing object containing property marked with [JsonExtensionData] attribute, serializer produces invalid JSON like this one: {"Id":1,{"nested":true}}

Reproduction Steps

Run the following code

var mix = new Mix
{
    Id = 1,
    Extra = new() { ["nested"] = true, }
};

var text = System.Text.Json.JsonSerializer.Serialize(mix);
Console.WriteLine(text);
// output {"Id":1,{"nested":true}}


public class Mix
{
    public int Id { get; set; }

    [System.Text.Json.Serialization.JsonExtensionData]
    public System.Text.Json.Nodes.JsonObject? Extra { get; set; }
}

Expected behavior

Correct JSON like {"Id":1,"nested":true} or at least valid JSON as if there was not [JsonExtensionData] attribute ({"Id":1,"Extra":{"nested":true}} ).

Actual behavior

Invalid JSON {"Id":1,{"nested":true}}

Regression?

Reproducible at least on .Net 6 to .Net 8

Known Workarounds

No response

Configuration

.Net 8
Windows 11
x64

Other information

No response</issue_description>

Comments on the Issue (you are @copilot in this section)

@ Tagging subscribers to this area: @dotnet/area-system-text-json, @gregsdennis See info in [area-owners.md](https://github.com/dotnet/runtime/blob/main/docs/area-owners.md) if you want to be subscribed.
Issue Details

Description

When serializing object containing property marked with [JsonExtensionData] attribute, serializer produces invalid JSON like this one: {"Id":1,{"nested":true}}

Reproduction Steps

Run the following code

var mix = new Mix
{
    Id = 1,
    Extra = new() { ["nested"] = true, }
};

var text = System.Text.Json.JsonSerializer.Serialize(mix);
Console.WriteLine(text);
// output {"Id":1,{"nested":true}}


public class Mix
{
    public int Id { get; set; }

    [System.Text.Json.Serialization.JsonExtensionData]
    public System.Text.Json.Nodes.JsonObject? Extra { get; set; }
}

Expected behavior

Correct JSON like {"Id":1,"nested":true} or at least valid JSON as if there was not [JsonExtensionData] attribute ({"Id":1,"Extra":{"nested":true}} ).

Actual behavior

Invalid JSON {"Id":1,{"nested":true}}

Regression?

Reproducible at least on .Net 6 to .Net 8

Known Workarounds

No response

Configuration

.Net 8
Windows 11
x64

Other information

No response

Author: KalininAndreyVictorovich
Assignees: -
Labels:

area-System.Text.Json

Milestone: -
@eiriktsarpalis Can confirm that this occurs. It seems we never added testing for the serialization scenario -- deserialization appears to be working as expected. @eiriktsarpalis The documentation is correctly stating that `JsonObject` is one of the supported types. However there is a bug specifically impacting serialization for the particular type (FWIW `JsonExtensionData` is a feature primarily oriented towards dserialization). @eiriktsarpalis You're right, I misread the documentation which appears to be out of date. The correct statement on supported types can actually be found in the error messages of the implementation itself:

<data name="DataExtensionPropertyInvalidFormat" xml:space="preserve">
<value>The data extension property '{0}.{1}' is invalid. It must implement 'IDictionary&lt;string, JsonElement&gt;' or 'IDictionary&lt;string, object&gt;', or be 'JsonObject'.</value>
</data>

In other words, what I mentioned earlier holds. JsonObject is supported and there is a bug specifically concerning serialization.</comment_new>
<comment_new>@eiriktsarpalis
It would help if you could file a separate issue in dotnet-api-docs. Thanks!</comment_new>


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

…sonObject

When serializing an object containing a property marked with [JsonExtensionData]
attribute of type JsonObject, the serializer was producing invalid JSON like
{"Id":1,{"nested":true}} instead of the correct {"Id":1,"nested":true}.

The fix modifies TryWriteDataExtensionProperty in JsonConverterOfT.cs to write
the JsonObject extension data contents without the wrapping braces, similar to
how dictionary extension data is handled.

Also added tests to verify the fix for JsonObject extension data serialization.

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix JsonSerializer.Serialize for JsonExtensionData property Fix JsonSerializer.Serialize producing invalid JSON for [JsonExtensionData] with JsonObject Jan 3, 2026
Copilot AI requested a review from stephentoub January 3, 2026 21:48
@stephentoub stephentoub marked this pull request as ready for review January 4, 2026 00:30
Copilot AI review requested due to automatic review settings January 4, 2026 00:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a bug where JsonSerializer.Serialize produced invalid JSON when serializing objects with [JsonExtensionData] properties of type JsonObject. The output was missing property names and directly including object braces (e.g., {"Id":1,{"nested":true}}), which is malformed JSON.

Key Changes:

  • Modified JsonConverterOfT.cs to iterate over JsonObject entries and write each key-value pair directly, ensuring extension data is properly flattened into the parent object
  • Added comprehensive test coverage for JsonObject extension data serialization scenarios

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs Replaced the TryWrite call for JsonObject extension data with explicit iteration and property-by-property writing to flatten extension data into the parent object
src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs Added 4 new tests covering valid JSON output, round-trip serialization, empty JsonObject, and null JsonObject scenarios, plus a test class definition

Comment on lines 463 to 483
Debug.Assert(Type == typeof(JsonObject));

// Write the JsonObject extension data contents without the wrapping braces.
// This is necessary because extension data properties should be flattened
// into the parent object, not nested as a separate object.
JsonObject jsonObject = (JsonObject)(object)value!;
foreach (KeyValuePair<string, JsonNode?> entry in jsonObject)
{
writer.WritePropertyName(entry.Key);

if (entry.Value is null)
{
writer.WriteNullValue();
}
else
{
entry.Value.WriteTo(writer, options);
}
}

return true;
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The depth validation is missing for the JsonObject extension data path. While WriteTo on JsonNode will eventually check depth through the Utf8JsonWriter, it's inconsistent with how the dictionary converter path handles depth checking (lines 486-488). Consider adding an explicit depth check before iterating, similar to the dictionary path, to fail fast and provide consistent error handling.

Copilot uses AI. Check for mistakes.
Comment on lines 463 to 483
Debug.Assert(Type == typeof(JsonObject));

// Write the JsonObject extension data contents without the wrapping braces.
// This is necessary because extension data properties should be flattened
// into the parent object, not nested as a separate object.
JsonObject jsonObject = (JsonObject)(object)value!;
foreach (KeyValuePair<string, JsonNode?> entry in jsonObject)
{
writer.WritePropertyName(entry.Key);

if (entry.Value is null)
{
writer.WriteNullValue();
}
else
{
entry.Value.WriteTo(writer, options);
}
}

return true;
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JsonObject extension data path doesn't maintain the WriteStack state by calling state.Push() and state.Pop() like the dictionary path does (lines 494, 512). This inconsistency could lead to issues with state tracking, particularly for continuation scenarios or complex nested structures. Consider adding proper state management to match the dictionary path's behavior.

Copilot uses AI. Check for mistakes.
if (dictionaryConverter == null)
{
// If not JsonDictionaryConverter<T> then we are JsonObject.
// Avoid a type reference to JsonObject and its converter to support trimming.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Avoid a type reference to JsonObject and its converter to support trimming.

Is this not true anymore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed that comment since we now use a direct type reference to JsonObject and call the new WriteContentsTo method. The previous comment was outdated - we already had a Debug.Assert(Type == typeof(JsonObject)) so there was already a type reference.

// This is necessary because extension data properties should be flattened
// into the parent object, not nested as a separate object.
JsonObject jsonObject = (JsonObject)(object)value!;
foreach (KeyValuePair<string, JsonNode?> entry in jsonObject)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it's copied from

foreach (KeyValuePair<string, JsonNode?> entry in Dictionary)
{
writer.WritePropertyName(entry.Key);
if (entry.Value is null)
{
writer.WriteNullValue();
}
else
{
entry.Value.WriteTo(writer, options);
}
}
but one optimization that's wasn't copied was the special handling of a JsonElement backed JsonObject. This might be fine, but I think it's probably better to have this code in JsonObject and avoid duplicating it if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added JsonObject.WriteContentsTo() internal method in 7f5a477. It handles both the JsonElement-backed case (using EnumerateObject()) and the dictionary-backed case, avoiding code duplication with WriteTo().

@stephentoub
Copy link
Member

@copilot, please address the feedback. Also please address the failing tests, e.g.

System.NotSupportedException : JsonTypeInfo metadata for type 'System.Text.Json.Serialization.Tests.ExtensionDataTests+ClassWithJsonObjectExtensionDataAndProperty' was not provided by TypeInfoResolver of type 'System.Text.Json.SourceGeneration.Tests.ExtensionDataTests_Default+ExtensionDataTestsContext_Default'. If using source generation, ensure that all root types passed to the serializer have been annotated with 'JsonSerializableAttribute', along with any types that might be serialized polymorphically.



Stack trace
   at System.Text.Json.ThrowHelper.ThrowNotSupportedException_NoMetadataForType(Type type, IJsonTypeInfoResolver resolver) in /_/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs:line 855
   at System.Text.Json.JsonSerializerOptions.GetTypeInfoInternal(Type type, Boolean ensureConfigured, Nullable`1 ensureNotNull, Boolean resolveIfMutable, Boolean fallBackToNearestAncestorType) in /_/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Caching.cs:line 126
   at System.Text.Json.JsonSerializerOptions.GetTypeInfoForRootType(Type type, Boolean fallBackToNearestAncestorType) in /_/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Caching.cs:line 153
   at System.Text.Json.JsonSerializer.GetTypeInfo(JsonSerializerOptions options, Type inputType) in /_/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Helpers.cs:line 45
   at System.Text.Json.JsonSerializer.GetTypeInfo[T](JsonSerializerOptions options) in /_/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Helpers.cs:line 53
   at System.Text.Json.JsonSerializer.Serialize[TValue](TValue value, JsonSerializerOptions options) in /_/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.String.cs:line 32
   at System.Text.Json.SourceGeneration.Tests.StringSerializerWrapper.SerializeWrapper[T](T value, JsonSerializerOptions options) in /_/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSerializerWrapper.SourceGen.cs:line 29
   at System.Text.Json.Serialization.Tests.ExtensionDataTests.SerializeJsonObjectExtensionData_EmptyJsonObject() in /_/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs:line 887
--- End of stack trace from previous location ---

…WriteContentsTo

- Add internal WriteContentsTo method to JsonObject that writes properties
  without wrapping braces, handling both dictionary-backed and JsonElement-backed cases
- Update TryWriteDataExtensionProperty to call the new method, avoiding code duplication
- Register ClassWithJsonObjectExtensionDataAndProperty in source generator contexts
  to fix failing source gen tests
- Remove unnecessary using directive

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jan 5, 2026

@copilot, please address the feedback. Also please address the failing tests, e.g.

System.NotSupportedException : JsonTypeInfo metadata for type 'System.Text.Json.Serialization.Tests.ExtensionDa...

Fixed in 7f5a477. Added ClassWithJsonObjectExtensionDataAndProperty to both source generator contexts (ExtensionDataTestsContext_Metadata and ExtensionDataTestsContext_Default) and addressed the feedback by moving the extension data writing logic to JsonObject.WriteContentsTo() method. All tests now pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JsonSerializer.Serialize produces invalid JSON for [JsonExtensionData] property

3 participants