Skip to content

Add JsonNamingPolicyAttribute for member-level naming policies in System.Text.Json#124645

Merged
eiriktsarpalis merged 14 commits intomainfrom
copilot/add-member-level-naming-policy
Mar 20, 2026
Merged

Add JsonNamingPolicyAttribute for member-level naming policies in System.Text.Json#124645
eiriktsarpalis merged 14 commits intomainfrom
copilot/add-member-level-naming-policy

Conversation

Copy link
Contributor

Copilot AI commented Feb 20, 2026

Description

System.Text.Json only supports naming policies globally via JsonSerializerOptions.PropertyNamingPolicy. This adds JsonNamingPolicyAttribute to allow granular naming policy control at the type, property, and field level.

API

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface |
    AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public class JsonNamingPolicyAttribute : JsonAttribute
{
    public JsonNamingPolicyAttribute(JsonKnownNamingPolicy namingPolicy);
    protected JsonNamingPolicyAttribute(JsonNamingPolicy namingPolicy);
    public JsonNamingPolicy NamingPolicy { get; }
}

Usage

var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
JsonSerializer.Serialize(new MyPoco(), options);
// {"myFirstProperty":"first","my-second-property":"second"}

[JsonNamingPolicyAttribute(JsonKnownNamingPolicy.CamelCase)]
public class MyPoco
{
    public string MyFirstProperty { get; set; } = "first";

    [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.KebabCaseLower)]
    public string MySecondProperty { get; set; } = "second";
}

Priority order

JsonPropertyName > member-level JsonNamingPolicyAttribute > type-level JsonNamingPolicyAttribute > JsonSerializerOptions.PropertyNamingPolicy > original name.

Changes

  • JsonNamingPolicyAttribute.cs: New attribute with public ctor for JsonKnownNamingPolicy and protected ctor for custom JsonNamingPolicy subclasses
  • DefaultJsonTypeInfoResolver.Helpers.cs: Type-level JsonNamingPolicyAttribute is resolved once per type in PopulateProperties and threaded through AddMembersDeclaredBySuperTypeAddMemberCreatePropertyInfo. DeterminePropertyName checks member-level then type-level attribute before falling back to global options. Integrated alongside the type-level JsonIgnoreAttribute resolution from Support generic converters on generic types with JsonConverterAttribute #123209.
  • Source generator (Parser.cs, KnownTypeSymbols.cs): Parses the attribute at type and member level using IsAssignableFrom (handles subclasses); derived attributes with custom policies that can't be resolved at compile time fall back to JsonKnownNamingPolicy.Unspecified — this flows through DetermineEffectiveJsonPropertyName where Unspecified maps to null (returning the original CLR name), and the non-null metadataJsonPropertyName is then emitted to prevent the runtime global PropertyNamingPolicy from incorrectly overriding. Both typeNamingPolicy and typeIgnoreCondition parameters are threaded through ProcessTypeCustomAttributesParsePropertyGenerationSpecsParsePropertyGenerationSpec.
  • Ref API: Added JsonNamingPolicyAttribute to public API surface
  • Tests: Serialization/deserialization coverage for type-level, member-level, mixed, and precedence scenarios across both reflection and source-gen paths. Tests use Assert.Equal with triple-quote strings on full JSON output. Includes tests for custom derived JsonNamingPolicyAttribute subclasses using the protected ctor — reflection tests verify the custom policy is applied correctly, and source gen tests verify both graceful fallback to CLR property names and that the global PropertyNamingPolicy is prevented from applying when a custom derived naming-policy attribute is present.
Original prompt

This section details on the original issue you should resolve

<issue_title>STJ should allow setting naming policies on the member level.</issue_title>
<issue_description>## Motivation

System.Text.Json supports user-defined naming policies for properties/fields which can be specified via the JsonSerializerOptions.PropertyNamingPolicy. Today such policies can only be specified globally which removes the ability to apply granular policies on the individual type/property/field level.

API Proposal

namespace System.Text.Json.Serialization;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface | AttributeTargets.AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public class JsonNamingPolicyAttribute : JsonAttribute
{
    public JsonNamingPolicy(JsonKnownNamingPolicy namingPolicy);
    protected JsonNamingPolicy(JsonNamingPolicy namingPolicy); // protected ctor for user-defined extensibility

    public JsonNamingPolicy NamingPolicy { get; }
}

API Usage

JsonSerializerOptions options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
JsonSerializer.Serialize(new MyPoco(), options); // { "myFirstProperty" : "first", "my-second-property": "second" }

[JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)]
public class MyPoco
{
    public string MyFirstProperty { get; set; } = "first";

    [JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)]
    public string MySecondProperty { get; set; } = "second";
}

cc @stephentoub</issue_description>

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

@eiriktsarpalis FYI @jeffhandley @PranavSenthilnathan something to consider for .NET 10. @eiriktsarpalis I personally prefer the explicit approach, ultimately it's a trade-off between writing less boilerplate and having an explicit contract. @stephentoub If I have a class with 20 members, I'd rather put one attribute on the class then one on each of the 20 properties.

As Eric says, it's a tradeoff between verbosity and explicitness.</comment_new>
<comment_new>@stephentoub

@stephentoub I think you make be missing my point about explicitness. I think using [JsonPropertyName()] is more explicit and intentional than applying a policy to an individual property. My point wasn't about global vs local.

In #108232 (comment), you asked about the advantage of:

[JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)]
public string MySecondProperty { get; set; }

vs

[JsonPropertyName("my-second-property")]
public string MySecondProperty { get; set; }

That's not the comparison that's interesting. The comparison that's interesting is:

[JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)]
class MyClass
{
    public string MyPropA { get; set; }
    public string MyPropB { get; set; }
    public string MyPropC { get; set; }
    public string MyPropD { get; set; }
    public string MyPropE { get; set; }
    public string MyPropF { get; set; }
    public string MyPropG { get; set; }
    public string MyPropH { get; set; }
}

vs

class MyClass
{
    [JsonPropertyName("my-prop-a")]
    public string MyPropA { get; set; }

    [JsonPropertyName("my-prop-b")]
    public string MyPropB { get; set; }

    [JsonPropertyName("my-prop-c")]
    public string MyPropC { get; set; }

    [JsonPropertyName("my-prop-d")]
    public string MyPropD { get; set; }

    [JsonPropertyName("my-prop-e")]
    public string MyPropE { get; set; }

    [JsonPropertyName("my-prop-f")]
    public string MyPropF { get; set; }

    [JsonPropertyName("my-prop-g")]
    public string MyPropG { get; set; }

    [JsonPropertyName("my-prop-h")]
    public string MyPropH { get; set; }
}

And there there's an obvious advantage of the former, that of brevity, maintenance, etc.

That advantage is then weighed against the slightly more explicit nature of JsonPropertyName. I say slightly because both are explicit; it's just a question of whether a constant is provided or whether a constant is provided with a formula over it, e.g. "6" vs "x == 3 and the formula is 2 * x". JsonPropertyName is arguably a bit more explicit, which you see as an advantage.

Hence, "it's a tradeoff between verbosity and explicitness".

I don't believe I'm missing the point.</comment_new>
<comment_new>@eiriktsarpalis

Happy to share the PR.

Feel free to create an implementation PR following the approved API shape.</comment_new>
<comment_new>@eiriktsarpalis

The example you posted of comparing a single policy attribute at th...


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

@dotnet-policy-service
Copy link
Contributor

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

Copilot AI and others added 2 commits February 20, 2026 15:26
…tor support

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
…ttribute

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for member-level naming policies in System.Text.Json Add JsonNamingPolicyAttribute for member-level naming policies in System.Text.Json Feb 20, 2026
Copilot AI requested a review from eiriktsarpalis February 20, 2026 15:39
Copy link
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

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

…e unresolvable derived attributes in source gen

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

Copilot AI commented Feb 20, 2026

@copilot

Addressed both review comments in 95b0107:

  1. Type-level JsonNamingPolicyAttribute is now resolved once per type in PopulateProperties, not per-member in CreatePropertyInfo.
  2. Derived JsonNamingPolicyAttribute subclasses with custom policies that can't be resolved at compile time now fall back to JsonKnownNamingPolicy.Unspecified instead of being silently discarded, preventing the global naming policy from incorrectly applying.

…tion and source gen paths

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Copy link
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

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

@copilot Add a test case where the protected attribute constructor is being used to specify a user-defined naming policy. This should be tested both in reflection and the source generator.

@eiriktsarpalis eiriktsarpalis marked this pull request as ready for review March 18, 2026 15:52
Copilot AI review requested due to automatic review settings March 18, 2026 15:52
Copilot AI requested a review from eiriktsarpalis March 18, 2026 15:52
Copy link
Member

@tarekgh tarekgh left a comment

Choose a reason for hiding this comment

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

Left small suggestions, LGTM otherwise.

@eiriktsarpalis
Copy link
Member

@copilot please address the new merge conflicts

@eiriktsarpalis
Copy link
Member

@copilot please address the merge conflicts.

Copy link
Contributor Author

Copilot AI commented Mar 19, 2026

@copilot please address the merge conflicts.

Resolved in 8c5da66. The conflicts were in JsonSourceGenerator.Parser.cs and DefaultJsonTypeInfoResolver.Helpers.cs where both my typeNamingPolicy and main's typeIgnoreCondition (from #123209) flow through the same parameter chains. Both are now integrated.

@eiriktsarpalis
Copy link
Member

/ba-g stalled CI legs

@eiriktsarpalis eiriktsarpalis merged commit 86e3d03 into main Mar 20, 2026
75 of 82 checks passed
@eiriktsarpalis eiriktsarpalis deleted the copilot/add-member-level-naming-policy branch March 20, 2026 09:26
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.

STJ should allow setting naming policies on the member level.

4 participants