Skip to content

Use UnsafeAccessor/reflection for inaccessible members in source-generated JsonTypeInfo#124650

Open
Copilot wants to merge 31 commits intomainfrom
copilot/fix-default-values-json-serialization-again
Open

Use UnsafeAccessor/reflection for inaccessible members in source-generated JsonTypeInfo#124650
Copilot wants to merge 31 commits intomainfrom
copilot/fix-default-values-json-serialization-again

Conversation

Copy link
Contributor

Copilot AI commented Feb 20, 2026

Description

The source generator uses [UnsafeAccessor] (NET8+ non-generic types) or cached reflection delegates (older targets/generic types) to access inaccessible members, enabling three previously unsupported scenarios:

1. Preserve default values for init-only properties

The source generator previously set init-only properties via the constructor delegate's object initializer expression, even when absent from JSON. This replaced C# property initializer defaults with default(T):

public class Foo
{
    public string Name { get; init; } = "DefaultName";
    public int Number { get; init; } = 42;
}
// Previously: static args => new Foo() { Name = (string)args[0], Number = (int)args[1] }
// Deserializing "{}" gave Name=null, Number=0 instead of Name="DefaultName", Number=42

Init-only properties are now removed from the constructor delegate and set post-construction via UnsafeAccessor or reflection, only when present in JSON.

2. Enable [JsonInclude] for inaccessible properties

Previously, [JsonInclude] properties with inaccessible getters or setters generated throwing delegates. They are now fully supported:

public class Bar
{
    [JsonInclude]
    private string Secret { get; set; } = "hidden";
}

3. Enable [JsonConstructor] for inaccessible constructors

Previously, inaccessible constructors marked with [JsonConstructor] were ignored with a SYSLIB1222 warning. They are now invoked via [UnsafeAccessor(UnsafeAccessorKind.Constructor)] or cached ConstructorInfo.Invoke reflection fallback:

public class Baz
{
    [JsonConstructor]
    private Baz(string name) { Name = name; }
    public string Name { get; }
}

Generated Code Samples

UnsafeAccessor path (NET8+ non-generic types)

For a type with a private [JsonInclude] property and an init-only property:

public class MyType
{
    [JsonInclude]
    private string Secret { get; set; } = "default";

    public int Value { get; init; } = 42;
}

The source generator emits [UnsafeAccessor] extern methods that are invoked directly from the getter/setter delegates. JsonPropertyInfoValues<T> is generic on the property type, so the Setter delegate is Action<object, T?> and the Getter is Func<object, T?>. The only cast in each delegate is on the object parameter to the declaring type:

Property getter (inaccessible [JsonInclude])

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Secret")]
private static extern string __get_MyType_Secret(MyType obj);

// Delegate: Func<object, string?>
Getter = static obj => __get_MyType_Secret((MyType)obj),

Property setter (inaccessible [JsonInclude])

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Secret")]
private static extern void __set_MyType_Secret(MyType obj, string value);

// Delegate: Action<object, string?>
Setter = static (obj, value) => __set_MyType_Secret((MyType)obj, value!),

Init-only property setter

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Value")]
private static extern void __set_MyType_Value(MyType obj, int value);

// Delegate: Action<object, int?>
Setter = static (obj, value) => __set_MyType_Value((MyType)obj, value!),

Constructor (inaccessible [JsonConstructor])

[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
private static extern Baz __ctor_Baz(string p0);

// Invoked directly from the parameterized constructor delegate

Value types (structs)

For struct types, the extern uses ref and the delegate passes ref Unsafe.Unbox<T>(obj):

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Value")]
private static extern void __set_MyStruct_Value(ref MyStruct obj, int value);

Setter = static (obj, value) => __set_MyStruct_Value(ref Unsafe.Unbox<MyStruct>(obj), value!),

Reflection fallback (older targets or generic types)

When [UnsafeAccessor] is not available (e.g., netstandard2.0 targets) or the declaring type is generic (not supported by UnsafeAccessor), the generator falls back to cached reflection with strongly typed wrappers:

Property getter (reflection)

private static Func<MyType, string>? s_get_MyType_Secret;
private static string __get_MyType_Secret(MyType obj) =>
    (s_get_MyType_Secret ??= (Func<MyType, string>)Delegate.CreateDelegate(
        typeof(Func<MyType, string>),
        typeof(MyType).GetProperty("Secret", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!
            .GetGetMethod(true)!))((MyType)obj);

Getter = static obj => __get_MyType_Secret((MyType)obj),

Property setter (reflection)

private static Action<MyType, string>? s_set_MyType_Secret;
private static void __set_MyType_Secret(MyType obj, string value) =>
    (s_set_MyType_Secret ??= (Action<MyType, string>)Delegate.CreateDelegate(
        typeof(Action<MyType, string>),
        typeof(MyType).GetProperty("Secret", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!
            .GetSetMethod(true)!))((MyType)obj, value);

Setter = static (obj, value) => __set_MyType_Secret((MyType)obj, value!),

Field accessor (reflection)

For fields, FieldInfo is cached directly since fields don't have MethodInfo equivalents:

private static FieldInfo? s_field_MyType_myField;
private static string __get_MyType_myField(object obj) =>
    (string)(s_field_MyType_myField ??= typeof(MyType).GetField("myField",
        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!).GetValue(obj)!;

private static void __set_MyType_myField(object obj, string value) =>
    (s_field_MyType_myField ??= typeof(MyType).GetField("myField",
        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!).SetValue(obj, value);

Constructor (reflection)

Note: Unlike MethodInfo, ConstructorInfo cannot be wrapped in a delegate, so the ConstructorInfo itself is cached:

private static ConstructorInfo? s_ctor_Baz;
private static Baz __ctor_Baz(string p0) =>
    (Baz)(s_ctor_Baz ??= typeof(Baz).GetConstructor(
        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
        binder: null, new Type[] { typeof(string) }, modifiers: null)!)
    .Invoke(new object?[] { p0 });

Changes

  • Parser: Non-required init-only properties removed from PropertyInitializerSpecs (renamed memberInitializerNames to requiredMemberNames). Inaccessible [JsonInclude] properties no longer flagged as HasInvalidConfigurationForFastPath. Inaccessible [JsonConstructor] constructors no longer nulled out — tracked as inaccessible for the emitter.

  • PropertyGenerationSpec: Added CanUseUnsafeAccessors (per-property, checks UnsafeAccessorAttribute availability and non-generic declaring type), NeedsAccessorForGetter, and NeedsAccessorForSetter.

  • TypeGenerationSpec: Added ConstructorIsInaccessible and CanUseUnsafeAccessorForConstructor.

  • Emitter: Unified accessor pattern with identical wrapper signatures for both paths. UnsafeAccessor externs are implementation details. Struct types use ref Unsafe.Unbox<T>(obj). Fast-path serialization includes inaccessible [JsonInclude] properties. Field reflection fallback correctly caches FieldInfo and calls .GetValue() / .SetValue().

  • KnownTypeSymbols: Detect UnsafeAccessorAttribute availability.

  • Runtime validation (JsonMetadataServices.Helpers.cs): Allow non-public [JsonInclude] properties when getter/setter delegates are provided.

  • Diagnostics: SYSLIB1038 and SYSLIB1222 no longer emitted for these scenarios.

  • Tests: Updated overrides, added new test types and methods including generic type coverage for the reflection fallback path (GenericClassWithPrivateJsonIncludeProperties<T>). All 7805 source gen tests pass.


🔒 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.

Copilot AI and others added 2 commits February 20, 2026 16:27
…TypeInfo

Remove non-required init-only properties from the constructor delegate's object
initializer expression. Instead, generate real setters using UnsafeAccessor (NET8+)
or cached reflection (older TFMs) so that C# default values are preserved when
properties are absent from JSON.

Co-authored-by: eiriktsarpalis <[email protected]>
Copilot AI changed the title [WIP] Fix default values for init-only properties with JsonTypeInfo Preserve default values for init-only properties in source-generated JsonTypeInfo Feb 20, 2026
Copilot AI requested a review from eiriktsarpalis February 20, 2026 16:31
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 Let's try to broaden the scope of this PR a little bit. We can also use unsafe accessors (or the reflection fall-back) to finally enable JsonIncludeAttribute support in the source generator. If a member is marked JsonInclude but is not accessible by the current generation context, we instruct the source generator to emit getter and setter delegates using unsafe accessors.

…flection, JsonInclude support, rename to requiredMembers

- Per-property unsafe accessor check: generic types can't use UnsafeAccessor
- Lazy PropertyInfo initialization via Lazy<T> for reflection fallback
- Enable JsonInclude for inaccessible members via unsafe accessors/reflection
- Rename memberInitializerNames to requiredMemberNames
- Add IsGenericType to TypeRef for per-property checks
- Generate getter accessors for inaccessible JsonInclude properties

Co-authored-by: eiriktsarpalis <[email protected]>
Copy link
Contributor Author

Copilot AI commented Feb 20, 2026

@copilot Let's try to broaden the scope of this PR a little bit. We can also use unsafe accessors (or the reflection fall-back) to finally enable JsonIncludeAttribute support in the source generator...

Broadened scope — [JsonInclude] properties with inaccessible getters/setters now generate real accessor delegates via [UnsafeAccessor] (when available) or Lazy<PropertyInfo> reflection fallback, instead of throwing. The InaccessibleJsonIncludePropertiesNotSupported diagnostic is suppressed since these members are now supported. See ccad3dd.

Copilot AI changed the title Preserve default values for init-only properties in source-generated JsonTypeInfo Preserve default values for init-only properties and enable JsonInclude for inaccessible members in source-generated JsonTypeInfo Feb 20, 2026
Copilot AI requested a review from eiriktsarpalis February 20, 2026 18:05
…cType from TypeRef

Move the per-property unsafe accessor eligibility check from the emitter
to the property model. The parser now computes CanUseUnsafeAccessors by
checking both UnsafeAccessorAttribute availability and whether the
declaring type is generic, using the type symbol directly.

This removes IsGenericType from TypeRef, IsUnsafeAccessorsSupported from
ContextGenerationSpec, and the _isUnsafeAccessorsSupported field from
the Emitter.

Co-authored-by: eiriktsarpalis <[email protected]>
…reflection fallback

Use a nullable PropertyInfo? static field with ??= lazy initialization
in a helper method, following the PolyType pattern. This avoids introducing
a Lazy<T> type dependency.

Co-authored-by: eiriktsarpalis <[email protected]>
Copy link
Contributor Author

Copilot AI commented Feb 20, 2026

Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/repos/eiriktsarpalis/PolyType/git/trees/d39e2448e64ebb409ae97d97093ba66fcd04c2a4
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (http block)

If you need me to access, download, or install something from one of these locations, you can either:

Replace PropertyInfo caching with Func/Action delegate caching for the
reflection fallback path. Instead of calling PropertyInfo.GetValue/SetValue
on each access, we now cache PropertyInfo.GetValue and PropertyInfo.SetValue
as Func<object?, object?> and Action<object?, object?> delegates respectively,
which provides faster subsequent access.

Co-authored-by: eiriktsarpalis <[email protected]>
@eiriktsarpalis
Copy link
Member

@copilot please address the merge conflicts.

Resolved merge conflict in System.Text.Json.SourceGeneration.Tests.targets:
- Kept SYSLIB1037 and SYSLIB1226 from main
- Excluded SYSLIB1038 and SYSLIB1222 (no longer emitted by our changes)

All 7805 source gen tests pass after merge.

Co-authored-by: eiriktsarpalis <[email protected]>
Copy link
Contributor Author

Copilot AI commented Mar 19, 2026

@copilot please address the merge conflicts.

Merged with main and resolved the conflict in System.Text.Json.SourceGeneration.Tests.targets (kept SYSLIB1037 and SYSLIB1226 from main, excluded SYSLIB1038/SYSLIB1222 since we no longer emit those). All 7805 source gen tests pass. See b897bec.

@eiriktsarpalis
Copy link
Member

@copilot please address the most recent merge conflicts

Resolved conflicts from main's addition of JsonNamingPolicyAttribute:
- Parser.cs: Added typeNamingPolicy parameter to ProcessTypeAttributes,
  ParsePropertyGenerationSpecs, and related methods
- DefaultJsonTypeInfoResolver.Helpers.cs: Added typeNamingPolicy parameter
  to AddMembersDeclaredBySuperType and CreatePropertyInfo
- PropertyVisibilityTests.cs: Kept our await base.X() delegation since
  inaccessible [JsonInclude] properties are now supported
- SourceGeneration.Tests.targets: Kept SYSLIB1038/SYSLIB1222 excluded
  since these diagnostics are no longer emitted
- Non-STJ conflicts (gc.cpp, crsttypes_generated.h, etc.): took main

Co-authored-by: eiriktsarpalis <[email protected]>
Copilot AI requested a review from sbomer as a code owner March 20, 2026 09:41
Copy link
Contributor Author

Copilot AI commented Mar 20, 2026

@copilot please address the most recent merge conflicts

Merged with main and resolved all conflicts in f7275aa. Key resolutions:

  • Parser.cs: Accepted main's typeNamingPolicy / JsonKnownNamingPolicy additions (6 conflict regions)
  • DefaultJsonTypeInfoResolver.Helpers.cs: Accepted main's typeNamingPolicy parameter additions
  • PropertyVisibilityTests.cs: Kept our await base.X() delegation (inaccessible members now supported)
  • SourceGeneration.Tests.targets: Kept SYSLIB1038/SYSLIB1222 excluded (no longer emitted)
  • Non-STJ files (gc.cpp, crsttypes_generated.h, etc.): took main's version

@dotnet-policy-service dotnet-policy-service bot added the linkable-framework Issues associated with delivering a linker friendly framework label Mar 20, 2026
@eiriktsarpalis
Copy link
Member

@copilot there still appear to be merge conflicts.

…alues-json-serialization-again

# Conflicts:
#	src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyVisibilityTests.cs
#	src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets
Copy link
Contributor Author

Copilot AI commented Mar 20, 2026

@copilot there still appear to be merge conflicts.

Resolved in fc6bdd2. Two conflicts from main's latest changes:

  • PropertyVisibilityTests.cs: kept our await base.X() delegation (inaccessible [JsonInclude] now supported)
  • Tests.targets: kept SYSLIB1038/SYSLIB1222 excluded (no longer emitted)

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

Labels

area-System.Text.Json linkable-framework Issues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants