Skip to content

Fix nested readonly collection ctor parameters - #131932

Merged
rosebyte merged 2 commits into
dotnet:mainfrom
rosebyte:rosebyte-fix-nested-readonly-collection-ctor-para
Aug 31, 2026
Merged

rosebyte merged 2 commits into
dotnet:mainfrom
rosebyte:rosebyte-fix-nested-readonly-collection-ctor-para

Conversation

@rosebyte

@rosebyte rosebyte commented Aug 6, 2026 •

Copy link
Copy Markdown
Member

Fixes #131399

Problem

The configuration binder source generator silently bound null/default for a nested member whose type's sole member is a constructor parameter of a read-only collection type:

public record Inner(IReadOnlyList<string> Values);

public class Outer
{
    public Inner? Nested { get; set; }          // bound null
    public InnerStruct Struct { get; set; }     // bound default
}

The reflection binder binds both correctly, so this was a silent divergence between the two engines: no diagnostic, no exception, just missing configuration at runtime.

#131358 fixed the equivalent top-level case (config.Get<Inner>()). The nested case remained broken, and this is a pre-existing gap rather than a regression from that change.

Root cause

Two independent bugs shared one symptom.

1. Reference types. EmitBindImplForMember skipped a parameterized-constructor object with no bindable members. But such a type binds its constructor parameters in the generated Initialize method regardless of whether it has properties for BindCore to mutate. Thanks to #131358, InitializeInner was emitted and CanInstantiate was true; the nested-member path simply never called it.

2. Value types. Relaxing that guard is not sufficient for structs:

  • EmitBindingLogicForComplexMember uses InitializationKind.None for value types because a struct returned by a property getter is a copy and cannot be bound in place.
  • EmitBindingLogic returns immediately on !HasBindableMembers when the initialization kind is None.

The result was an inert temporary and a member left at default, even though InitializeInnerStruct could create the correct value.

Fix

The existing member predicate is extracted into IsBindableAsMember and extended with exactly one term:

private bool IsBindableAsMember(ComplexTypeSpec complexType, bool canSet) =>
    _typeIndex.HasBindableMembers(complexType) ||
    complexType.IsValueType ||
    complexType is not ObjectSpec { InstantiationStrategy: ObjectInstantiationStrategy.ParameterizedConstructor } ||
    (canSet && _typeIndex.CanInstantiate(complexType));

This is the old predicate plus (canSet && CanInstantiate). Existing value-type and collection decisions remain unchanged, limiting generated-output changes to the affected constructor-bound shapes.

IsPropertyReboundInBindCore uses the same predicate. This is required for correctness, not merely deduplication: a constructor parameter can match a set-only property. If the two decisions diverge, Initialize constructs the nested value for the parent constructor and BindCore then emits a second unconditional assignment through that property. Sharing the predicate lets the existing boundThroughConstructor mechanism skip the second assignment for newly constructed parents while retaining it for Bind(existingInstance).

For constructor-only value types, EmitBindingLogicForComplexMember now directly emits:

instance.Nested = InitializeInnerStruct(section, binderOptions);

There is nothing to bind in place, so this avoids the general temporary path and assigns the only meaningful result directly.

canSet

The new reference-type term requires canSet because the generated Initialize result must be assigned to the member. Constructor-parameter locals pass canSet: true, so init-only positional-record members continue to bind through their matching constructor parameters. A get-only member with no bindable members remains skipped because there is nowhere to put a new instance.

The pre-existing value-type predicate is deliberately preserved. EmitBindingLogicForComplexMember already returns when a value-type member cannot be set; retaining the old predicate avoids unrelated generated-signature and dead-block changes for get-only struct members.

Generated code

Before, for class Outer { public Inner? Nested { get; set; } }:

public static void BindCore(IConfiguration configuration, ref Outer instance, ...)
{
    // Nested was skipped entirely
}

After:

var value1 = configuration.GetSection("Nested");
if (AsConfigWithChildren(value1) is IConfigurationSection section2)
{
    instance.Nested ??= InitializeInner(section2, binderOptions);
}

The reference/value asymmetry follows the existing emitter model:

  • A reference-type member gets ??=, preserving an instance already stored on the member.
  • A value-type member gets =. A struct getter returns a copy, ??= is unavailable for non-nullable structs, and using it for a populated nullable struct would ignore a present configuration section because this type has no BindCore work to perform.

Scope and performance

The fix does not change HasBindableMembers, CanInstantiate, helper registration, or the general value-type initialization strategy. Unaffected shapes retain the old predicate result and generated path. Affected shapes add only the previously missing section lookup and Initialize assignment; parent types whose matching constructor properties now emit binding logic use the existing boundThroughConstructor branch to prevent a second assignment.

The extra generator-time CanInstantiate check is reached only for the previously rejected parameterized-object shape and is an O(1) object-spec check. No runtime benchmark is meaningful here because the baseline silently omits the required binding work; the changed path necessarily performs that work.

Tests

Coverage includes:

  • nested reference, struct, and nullable-struct members;
  • constructor parameters, settable properties, and matching constructor/property pairs;
  • the copy-constructor collection interfaces and IReadOnlyDictionary<,>'s LinqToDictionary strategy;
  • nullable struct constructor parameters; and
  • a set-only property matching a constructor parameter, proving the shared predicate prevents a second initialization and setter call.

Shared Common tests run under both reflection and source-generated binding to pin parity for the fixed fresh-binding scenarios.

Note

Parts of this description were drafted with GitHub Copilot.

Copilot AI lite review requested due to automatic review settings August 6, 2026 11:38
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 configuration binding source-generator parity gap where certain nested complex members were skipped during generated binding, leaving them null/default instead of being constructed and populated as the reflection-based binder would.

Changes:

  • Refactors the “should we emit binding code for this complex member?” decision into IsBindableAsMember and applies it consistently to both EmitBindImplForMember and IsPropertyReboundInBindCore.
  • Updates value-type complex-member binding to directly assign the instance created by Initialize(...) when the type has no bindable members beyond constructor parameters.
  • Adds new regression tests covering nested binding for both reference types and value types (including nullable structs), plus a reflection-binder test for the same shape.

Reviewed changes

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

File Description
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/SourceGenerationTests/GeneratorTests.cs Adds generator regression tests for nested binding of “sole read-only collection ctor parameter” shapes (class + struct).
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.Collections.cs Introduces test types used to validate nested binding behavior in reflection-binder tests.
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.Collections.cs Adds a reflection-binder regression test verifying nested binding for the same type shapes.
src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs Fixes emitter logic so nested members that bind via constructor parameters are no longer skipped; adds a value-type fast path to assign initialized instances.

@rosebyte rosebyte changed the title Rosebyte fix nested readonly collection ctor para Fix nested readonly collection ctor parameters Aug 6, 2026
@tarekgh tarekgh added the source-generator Indicates an issue with a source generator feature label Aug 6, 2026
@rosebyte
rosebyte force-pushed the rosebyte-fix-nested-readonly-collection-ctor-para branch from 30ebc1e to 06beb5a Compare August 24, 2026 11:42
@rosebyte
rosebyte marked this pull request as ready for review August 24, 2026 11:43
Copilot AI review requested due to automatic review settings August 24, 2026 11:43
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

@rosebyte
rosebyte merged commit 6e04bea into dotnet:main Aug 31, 2026
78 checks passed
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-Extensions-Configuration source-generator Indicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration binder source generator silently binds null for a nested type whose sole member is a read-only collection constructor parameter

4 participants