Skip to content

Fix nested readonly collection ctor parameters - #131932

Draft
rosebyte wants to merge 2 commits into
dotnet:mainfrom
rosebyte:rosebyte-fix-nested-readonly-collection-ctor-para
Draft

Fix nested readonly collection ctor parameters#131932
rosebyte wants to merge 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 sharing one symptom.

1. Reference types. EmitBindImplForMember skipped any complex member with no bindable members. But a type with a parameterized constructor binds its parameters in the generated Initialize method regardless of what other members it has. Thanks to #131358 InitializeInner was being emitted and CanInstantiate was true; nothing ever called it.

The guard is now extracted into IsBindableAsMember, with the missing term added: a type that can be instantiated is bindable through its constructor even with no bindable members of its own, provided the member can actually be assigned. The near-duplicate logic in IsPropertyReboundInBindCore now calls the same helper, so the two cannot drift apart.

2. Value types. Relaxing the guard alone gets you through the door into an empty room, because two conditions both had to change:

  • EmitBindingLogicForComplexMember hard-codes InitializationKind.None for value types, since a struct behind a property getter is a copy and cannot be bound in place.
  • EmitBindingLogic then early-returns on !HasBindableMembers unless the initialization kind is something other than None.

Such a type is created outright by Initialize, so the value-type branch now short-circuits to SimpleAssignment and assigns that instance directly to the member.

The struct case was arguably worse than plain default: a non-nullable struct parameter threw ParameterHasNoMatchingConfig when the section was absent, and silently bound default when it was present — close to backwards. This corrects the present case; the absent-section throw stays, matching every other non-nullable parameter.

canSet

Both terms require the member to be settable. For a get-only or init-only member there is nothing to assign, so un-skipping it emitted only a dead block plus a spurious boundThroughConstructor parameter on BindCore. For value types this is a small behavioural change worth calling out: dead blocks previously emitted for get-only struct members are gone.

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:

if (AsConfigWithChildren(configuration.GetSection("Nested")) is IConfigurationSection section)
{
    instance.Nested = InitializeInner(section, binderOptions);
}

rosebyte and others added 2 commits August 6, 2026 13:11
… collection ctor param

Fixes dotnet#131399

If a bound type had a member whose own type's sole member was a read-only
collection constructor parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T> or IEnumerable<T>, with no other bindable property), the
generator left that member at null, with no exception and no diagnostic. The
equivalent top-level shape, which goes through GetCore, binds correctly.

Root cause: EmitBindImplForMember's early-return guard skipped a complex member
whenever HasBindableMembers was false, without accounting for a
parameterized-constructor type that is still instantiable. Such a type binds its
constructor parameters in its generated Initialize method regardless of whether
it has any other bindable member, so Initialize<T> was generated and
CanInstantiate<T> was true, but nothing ever called it.

Fix: treat such a member as bindable when the type can be instantiated and the
member can be assigned the instance Initialize creates. Requiring canSet matters:
for a get-only or init-only member there is nothing to assign, and un-skipping it
would emit a dead `if (temp is not null) { }` block plus a spurious
boundThroughConstructor parameter on BindCore.

The guard moves into IsBindableAsMember, which now also backs
IsPropertyReboundInBindCore. That method previously duplicated the guard's
negation inline and has to stay in sync with it, since it decides whether a
property already bound in Initialize is deferred into an
`if (!boundThroughConstructor)` block in BindCore to avoid double-binding.

The added theory covers every way such a member is reached - a constructor
parameter, a settable property, and a settable property with a matching
constructor parameter, the last both through Get<T>() and Bind(existingInstance)
- for all four collection interfaces. It binds null on each shape without the
emitter change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98d6b6b8-446a-41b8-a59e-0e13f9ea3783
…ection constructor parameter

The source generator bound `default` for a nested struct member whose only
member is a constructor parameter of a read-only collection type, where the
reflection binder binds it correctly. Two conditions kept the value-type path
inert:

* `EmitBindingLogicForComplexMember` hard-codes `InitializationKind.None` for
  value types, because a struct behind a property getter is a copy and so
  cannot be bound in place.
* `EmitBindingLogic` then early-returns on `!HasBindableMembers` unless the
  initialization kind is something other than `None`.

Relaxing the member guard alone therefore got as far as the door and no
further. Such a type is created outright by its generated `Initialize` method,
so the value-type branch now short-circuits to `SimpleAssignment` and assigns
that instance straight to the member.

The value-type term in `IsBindableAsMember` is also tightened to require a
setter. Binding a value type in place would only mutate the copy the getter
returns, so a get-only or init-only struct member previously produced a dead
block in the generated output; it no longer does.

Covered by a shared test in tests/Common, which runs against both the
reflection binder and the generator and so pins the two engines together. It
fails against the generator without this change, binding null where reflection
binds the configured value.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98d6b6b8-446a-41b8-a59e-0e13f9ea3783
Copilot AI 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
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

3 participants