From 2dd7de71e180129a94de5294675d826fc78c62d6 Mon Sep 17 00:00:00 2001 From: Jiri Cincura Date: Wed, 18 Mar 2026 15:05:40 +0100 Subject: [PATCH 01/14] Allow configuration binding to set-only properties Fixes #63508 The configuration binder previously skipped all properties without a getter. This change enables binding to true set-only properties (those with a setter but no getter at all) in both the reflection-based binder and the source generator. Reflection binder (ConfigurationBinder.cs): - Restructured the BindProperty guard to allow set-only properties with accessible setters while still filtering indexers and non-public properties. - Set-only properties use a null initial value instead of calling property.GetValue(), avoiding the exception that would occur with no getter. Source generator (CoreBindingHelpers.cs): - Added HasAnyGetter to MemberSpec/PropertySpec to distinguish true set-only properties (no getter) from properties with non-public getters, keeping behavior consistent with the reflection binder. - Relaxed the parsable type guard from canSet && canGet to canSet && (canGet || !member.HasAnyGetter). - Fixed complex reference type binding for set-only properties to use a temp variable instead of ref on the property (CS0206). - Added canGet guard on the defaultValueIfNotFound block and array null-check fallback that read the current property value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 26 ++++++++--- .../gen/Specs/Members/MemberSpec.cs | 7 +++ .../gen/Specs/Members/PropertySpec.cs | 3 ++ .../src/ConfigurationBinder.cs | 35 ++++++++++---- .../ConfigurationBinderTests.TestClasses.cs | 27 +++++++++-- .../tests/Common/ConfigurationBinderTests.cs | 46 +++++++++++++++++-- 6 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index 42979bf896c101..ac020145d6ffc9 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -887,8 +887,10 @@ private bool EmitBindImplForMember( { case ParsableFromStringSpec stringParsableType: { - // Reflection binder does not support binding to set-only properties - if (canSet && canGet) + // Bind when the property has a public setter AND either has a public getter + // or is truly set-only (no getter at all). Properties with non-public + // getters are skipped to match the reflection binder behavior. + if (canSet && (canGet || !member.HasAnyGetter)) { EmitBlankLineIfRequired(); string valueIdentifier = GetIncrementalIdentifier(Identifier.value); @@ -923,7 +925,9 @@ private bool EmitBindImplForMember( EmitEndBlock(); // End if-check for input type. - if (initializationKind == InitializationKind.Declaration) + // The defaultValueIfNotFound block reads the current value via the getter, + // so it can only be emitted when there is a getter. + if (canGet && initializationKind == InitializationKind.Declaration) { EmitStartBlock($"else if (defaultValueIfNotFound)"); if (!stringParsableType.TypeRef.CanBeNull) @@ -977,7 +981,8 @@ complexType is not CollectionSpec && // The current configuration section doesn't have any children, let's check if we are binding to an array and the configuration value is empty string. // In this case, we will assign an empty array to the member. Otherwise, we will skip the binding logic. - if ((complexType is ArraySpec || complexType.IsExactIEnumerableOfT) && canSet) + // The null check on the member requires a getter, so skip this fallback for set-only properties. + if ((complexType is ArraySpec || complexType.IsExactIEnumerableOfT) && canSet && canGet) { // Either we have an array or we have an IEnumerable both these types can be assigned an empty array when having empty string configuration value. Debug.Assert(complexType is ArraySpec || complexType is EnumerableSpec); @@ -1019,7 +1024,12 @@ private void EmitBindingLogicForComplexMember( string effectiveMemberTypeFQN = effectiveMemberType.TypeRef.FullyQualifiedName; initKind = InitializationKind.None; - if (memberType is NullableSpec) + if (!member.CanGet) + { + // Set-only property: initialize to default since we can't read the current value. + _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = new {effectiveMemberTypeFQN}();"); + } + else if (memberType is NullableSpec) { string nullableTempIdentifier = GetIncrementalIdentifier(Identifier.temp); @@ -1042,8 +1052,10 @@ private void EmitBindingLogicForComplexMember( } else { - targetObjAccessExpr = memberAccessExpr; - initKind = InitializationKind.SimpleAssignment; + // When CanGet is false, the property can't be passed by ref (CS0206). + // Use a temp variable and assign back after binding. + targetObjAccessExpr = tempIdentifier; + initKind = InitializationKind.Declaration; } Action? writeOnSuccess = !canSet diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/MemberSpec.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/MemberSpec.cs index cbc205ec2976ff..bdf5378a4eadce 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/MemberSpec.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/MemberSpec.cs @@ -25,5 +25,12 @@ public MemberSpec(ISymbol member, TypeRef typeRef) public abstract bool CanGet { get; } public abstract bool CanSet { get; } + + /// + /// Whether the member has a getter of any accessibility. + /// Used to distinguish true set-only properties (no getter at all) from + /// properties with non-public getters. + /// + public virtual bool HasAnyGetter => CanGet; } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/PropertySpec.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/PropertySpec.cs index 66257d06cab891..361fd0f39f5d49 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/PropertySpec.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Specs/Members/PropertySpec.cs @@ -18,6 +18,7 @@ public PropertySpec(IPropertySymbol property, TypeRef typeRef) : base(property, SetOnInit = setterIsPublic && (property.IsRequired || isInitOnly); CanSet = setterIsPublic && !isInitOnly; CanGet = property.GetMethod?.DeclaredAccessibility is Accessibility.Public; + HasAnyGetter = property.GetMethod is not null; } public ParameterSpec? MatchingCtorParam { get; set; } @@ -29,5 +30,7 @@ public PropertySpec(IPropertySymbol property, TypeRef typeRef) : base(property, public override bool CanGet { get; } public override bool CanSet { get; } + + public override bool HasAnyGetter { get; } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs index 8166b108da3f31..0895e735c9ae51 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs @@ -286,17 +286,36 @@ property.SetMethod is null || [RequiresUnreferencedCode(PropertyTrimmingWarningMessage)] private static void BindProperty(PropertyInfo property, object instance, IConfiguration config, BinderOptions options) { - // We don't support set only, non public, or indexer properties - if (property.GetMethod == null || - (!options.BindNonPublicProperties && !property.GetMethod.IsPublic) || - property.GetMethod.GetParameters().Length > 0) + // We don't support non public or indexer properties + if (property.GetMethod is { } getMethod) { - return; + if ((!options.BindNonPublicProperties && !getMethod.IsPublic) || + getMethod.GetParameters().Length > 0) + { + return; + } } + else + { + // Set-only property: need an accessible setter to be useful. + // Also filter out set-only indexer properties (setter has more than just the value parameter). + if (property.SetMethod is null || + (!options.BindNonPublicProperties && !property.SetMethod.IsPublic) || + property.SetMethod.GetParameters().Length > 1) + { + return; + } + } + + bool hasGetter = property.GetMethod is not null; - var propertyBindingPoint = new BindingPoint( - initialValueProvider: () => property.GetValue(instance), - isReadOnly: property.SetMethod is null || (!property.SetMethod.IsPublic && !options.BindNonPublicProperties)); + var propertyBindingPoint = hasGetter + ? new BindingPoint( + initialValueProvider: () => property.GetValue(instance), + isReadOnly: property.SetMethod is null || (!property.SetMethod.IsPublic && !options.BindNonPublicProperties)) + : new BindingPoint( + initialValue: null, + isReadOnly: false); BindInstance( property.PropertyType, diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs index 78f7cc9e27a554..ac1797acbc4587 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs @@ -630,11 +630,28 @@ public class SimplePocoWithOnlyDefaults public class SetOnlyPoco { - private bool _AnyCalled; - public bool AnyCalled => _AnyCalled; - public string SetOnly { set => _AnyCalled |= true; } - public string PrivateGetter { private get => "foo"; set => _AnyCalled |= true; } - public string InitOnly { init => _AnyCalled |= true; } + private bool _setOnlyCalled; + private bool _privateGetterCalled; + private bool _initOnlyCalled; + public bool SetOnlyCalled => _setOnlyCalled; + public bool PrivateGetterCalled => _privateGetterCalled; + public bool InitOnlyCalled => _initOnlyCalled; + public string SetOnly { set => _setOnlyCalled = true; } + public string PrivateGetter { private get => "foo"; set => _privateGetterCalled = true; } + public string InitOnly { init => _initOnlyCalled = true; } + } + + public class ComplexSetOnlyPoco + { + private SimplePoco _complex; + public SimplePoco GetComplex() => _complex; + public SimplePoco Complex { set => _complex = value; } + } + + public class SetOnlyWithTypeConversionPoco + { + public double TimeoutSeconds { set => Timeout = TimeSpan.FromSeconds(value); } + public TimeSpan Timeout { get; private set; } } public interface ISomeInterface diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs index 15514263abea49..a302eff91b7bb0 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs @@ -1376,7 +1376,7 @@ public void CanBindSemiImmutableClass_WithInitProperties() } [Fact] - public void DoesNotCallSetOnly() + public void BindsSetOnlyProperties() { var dic = new Dictionary { @@ -1389,7 +1389,47 @@ public void DoesNotCallSetOnly() var config = configurationBuilder.Build(); var options = config.Get(); - Assert.False(options.AnyCalled); + Assert.True(options.SetOnlyCalled); + Assert.False(options.PrivateGetterCalled); +#if BUILDING_SOURCE_GENERATOR_TESTS + // Source generator treats init-only properties separately (SetOnInit); + // they are not bound through the normal property binding path. + Assert.False(options.InitOnlyCalled); +#else + // Reflection binder binds init-only set-only properties via PropertyInfo.SetValue. + Assert.True(options.InitOnlyCalled); +#endif + } + + [Fact] + public void BindsSetOnlyPropertiesWithTypeConversion() + { + var dic = new Dictionary + { + {"TimeoutSeconds", "30.5"}, + }; + var configurationBuilder = new ConfigurationBuilder(); + configurationBuilder.AddInMemoryCollection(dic); + var config = configurationBuilder.Build(); + + var options = config.Get(); + Assert.Equal(TimeSpan.FromSeconds(30.5), options.Timeout); + } + + [Fact] + public void BindsSetOnlyComplexProperties() + { + var dic = new Dictionary + { + {"Complex:A", "Test"}, + }; + var configurationBuilder = new ConfigurationBuilder(); + configurationBuilder.AddInMemoryCollection(dic); + var config = configurationBuilder.Build(); + + var options = config.Get(); + Assert.NotNull(options.GetComplex()); + Assert.Equal("Test", options.GetComplex().A); } [Fact] @@ -1939,7 +1979,7 @@ public void CanBindVirtualProperties() Assert.Equal("3", test.TestGetOverridden); Assert.Equal("4", test.TestSetOverridden); Assert.Equal("5", test.TestNoOverridden); - Assert.Null(test.ExposeTestVirtualSet()); + Assert.Equal("6", test.ExposeTestVirtualSet()); } [Fact] From e2bc3dfedd7878bf40b61e21d2b28e52d7147ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Thu, 19 Mar 2026 12:13:01 +0100 Subject: [PATCH 02/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index ac020145d6ffc9..a7810b44d447d2 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -1027,7 +1027,7 @@ private void EmitBindingLogicForComplexMember( if (!member.CanGet) { // Set-only property: initialize to default since we can't read the current value. - _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = new {effectiveMemberTypeFQN}();"); + _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = default;"); } else if (memberType is NullableSpec) { @@ -1036,7 +1036,7 @@ private void EmitBindingLogicForComplexMember( _writer.WriteLine($"{memberType.TypeRef.FullyQualifiedName} {nullableTempIdentifier} = {memberAccessExpr};"); _writer.WriteLine( - $"{effectiveMemberTypeFQN} {tempIdentifier} = {nullableTempIdentifier}.{Identifier.HasValue} ? {nullableTempIdentifier}.{Identifier.Value} : new {effectiveMemberTypeFQN}();"); + $"{effectiveMemberTypeFQN} {tempIdentifier} = {nullableTempIdentifier}.{Identifier.HasValue} ? {nullableTempIdentifier}.{Identifier.Value} : default;"); } else { From 267a969067870f0d2a7e4b02b04f52e637e08111 Mon Sep 17 00:00:00 2001 From: Jiri Cincura Date: Thu, 19 Mar 2026 12:14:33 +0100 Subject: [PATCH 03/14] Update comments based on Copilot's suggestion. --- .../src/ConfigurationBinder.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs index 0895e735c9ae51..bf257498b5f669 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs @@ -270,7 +270,8 @@ private static void BindProperties(object instance, IConfiguration configuration [RequiresUnreferencedCode(PropertyTrimmingWarningMessage)] private static void ResetPropertyValue(PropertyInfo property, object instance, BinderOptions options) { - // We don't support set only, non public, or indexer properties + // We don't support indexer properties, or properties without both a getter and a setter. + // Access to non-public accessors is controlled by BindNonPublicProperties. if (property.GetMethod is null || property.SetMethod is null || (!options.BindNonPublicProperties && (!property.GetMethod.IsPublic || !property.SetMethod.IsPublic)) || @@ -286,7 +287,7 @@ property.SetMethod is null || [RequiresUnreferencedCode(PropertyTrimmingWarningMessage)] private static void BindProperty(PropertyInfo property, object instance, IConfiguration config, BinderOptions options) { - // We don't support non public or indexer properties + // Indexer properties are not supported. Access to non-public accessors is controlled by BindNonPublicProperties. if (property.GetMethod is { } getMethod) { if ((!options.BindNonPublicProperties && !getMethod.IsPublic) || From f49b7cce28087d9a568b2904eab9dfdbc2a3e17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Thu, 19 Mar 2026 13:34:29 +0100 Subject: [PATCH 04/14] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index a7810b44d447d2..cb7af8b4bef066 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -1026,7 +1026,14 @@ private void EmitBindingLogicForComplexMember( if (!member.CanGet) { - // Set-only property: initialize to default since we can't read the current value. + if (member.HasAnyGetter) + { + // Property has a non-public getter that cannot be used here; skip binding to + // match the behavior of the reflection-based binder. + return; + } + + // Truly set-only property (no getter at all): initialize to default since we can't read the current value. _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = default;"); } else if (memberType is NullableSpec) @@ -1050,13 +1057,24 @@ private void EmitBindingLogicForComplexMember( targetObjAccessExpr = memberAccessExpr; initKind = InitializationKind.AssignmentWithNullCheck; } - else + else if (!member.HasAnyGetter) { - // When CanGet is false, the property can't be passed by ref (CS0206). + // When there is no getter at all, the property can't be passed by ref (CS0206). // Use a temp variable and assign back after binding. + if (!_typeIndex.CanInstantiate(effectiveMemberType)) + { + return; + } + targetObjAccessExpr = tempIdentifier; initKind = InitializationKind.Declaration; } + else + { + // Property has a non-public getter that cannot be used here; skip binding to + // match the behavior of the reflection-based binder. + return; + } Action? writeOnSuccess = !canSet ? null From cbc24b674c934642c30f26ed1d9988de112438db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Thu, 19 Mar 2026 13:44:17 +0100 Subject: [PATCH 05/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index cb7af8b4bef066..473d30231c5b31 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -981,15 +981,26 @@ complexType is not CollectionSpec && // The current configuration section doesn't have any children, let's check if we are binding to an array and the configuration value is empty string. // In this case, we will assign an empty array to the member. Otherwise, we will skip the binding logic. - // The null check on the member requires a getter, so skip this fallback for set-only properties. - if ((complexType is ArraySpec || complexType.IsExactIEnumerableOfT) && canSet && canGet) + if ((complexType is ArraySpec || complexType.IsExactIEnumerableOfT) && canSet) { - // Either we have an array or we have an IEnumerable both these types can be assigned an empty array when having empty string configuration value. + // Either we have an array or we have an IEnumerable; both these types can be assigned an empty array when having empty string configuration value. Debug.Assert(complexType is ArraySpec || complexType is EnumerableSpec); string valueIdentifier = GetIncrementalIdentifier(Identifier.value); - EmitStartBlock($@"if ({memberAccessExpr} is null && {Identifier.TryGetConfigurationValue}({configSection}, {Identifier.key}: null, out string? {valueIdentifier}) && {valueIdentifier} == string.Empty)"); - _writer.WriteLine($"{memberAccessExpr} = global::System.{Identifier.Array}.Empty<{((CollectionSpec)complexType).ElementTypeRef.FullyQualifiedName}>();"); - EmitEndBlock(); + + if (canGet) + { + // For properties with getters, only assign the empty array when the current value is null. + EmitStartBlock($@"if ({memberAccessExpr} is null && {Identifier.TryGetConfigurationValue}({configSection}, {Identifier.key}: null, out string? {valueIdentifier}) && {valueIdentifier} == string.Empty)"); + _writer.WriteLine($"{memberAccessExpr} = global::System.{Identifier.Array}.Empty<{((CollectionSpec)complexType).ElementTypeRef.FullyQualifiedName}>();"); + EmitEndBlock(); + } + else + { + // For true set-only properties (no getter), we cannot read the current value; assign the empty array when the configuration value is empty string. + EmitStartBlock($@"if ({Identifier.TryGetConfigurationValue}({configSection}, {Identifier.key}: null, out string? {valueIdentifier}) && {valueIdentifier} == string.Empty)"); + _writer.WriteLine($"{memberAccessExpr} = global::System.{Identifier.Array}.Empty<{((CollectionSpec)complexType).ElementTypeRef.FullyQualifiedName}>();"); + EmitEndBlock(); + } } return _typeIndex.CanInstantiate(complexType); From 571bf809a87d4b2fbac87dea26a3d976dd9d44af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Thu, 19 Mar 2026 13:54:12 +0100 Subject: [PATCH 06/14] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index 473d30231c5b31..76e23b78b9393a 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -1044,8 +1044,14 @@ private void EmitBindingLogicForComplexMember( return; } - // Truly set-only property (no getter at all): initialize to default since we can't read the current value. - _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = default;"); + // Truly set-only property (no getter at all): initialize to an appropriate default value + // since we can't read the current value. For value types, use a constructor call to match + // Activator.CreateInstance behavior (which honors user-defined parameterless ctors). + string initializer = effectiveMemberType.IsValueType + ? $"new {effectiveMemberTypeFQN}()" + : "default"; + + _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = {initializer};"); } else if (memberType is NullableSpec) { @@ -1054,7 +1060,7 @@ private void EmitBindingLogicForComplexMember( _writer.WriteLine($"{memberType.TypeRef.FullyQualifiedName} {nullableTempIdentifier} = {memberAccessExpr};"); _writer.WriteLine( - $"{effectiveMemberTypeFQN} {tempIdentifier} = {nullableTempIdentifier}.{Identifier.HasValue} ? {nullableTempIdentifier}.{Identifier.Value} : default;"); + $"{effectiveMemberTypeFQN} {tempIdentifier} = {nullableTempIdentifier}.{Identifier.HasValue} ? {nullableTempIdentifier}.{Identifier.Value} : new {effectiveMemberTypeFQN}();"); } else { From 976e038034aa700e957645872e7be91725b3f4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Thu, 19 Mar 2026 14:10:03 +0100 Subject: [PATCH 07/14] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 4 +--- .../src/ConfigurationBinder.cs | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index 76e23b78b9393a..4f7f3be454e2e3 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -1047,9 +1047,7 @@ private void EmitBindingLogicForComplexMember( // Truly set-only property (no getter at all): initialize to an appropriate default value // since we can't read the current value. For value types, use a constructor call to match // Activator.CreateInstance behavior (which honors user-defined parameterless ctors). - string initializer = effectiveMemberType.IsValueType - ? $"new {effectiveMemberTypeFQN}()" - : "default"; + string initializer = $"new {effectiveMemberTypeFQN}();"; _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = {initializer};"); } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs index bf257498b5f669..a0918f9e2f1e71 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs @@ -329,7 +329,12 @@ private static void BindProperty(PropertyInfo property, object instance, IConfig // As example, when binding a property which not having a configuration entry matching this property and the getter can initialize the Value. // It is important to call the property setter as the setters can have a logic adjusting the Value. // Otherwise, if the HasNewValue set to true, it means that the property setter should be called anyway as encountering a new value. - if (!propertyBindingPoint.IsReadOnly && (propertyBindingPoint.Value is not null || propertyBindingPoint.HasNewValue)) + bool isNonNullableValueType = property.PropertyType.IsValueType && + Nullable.GetUnderlyingType(property.PropertyType) is null; + + if (!propertyBindingPoint.IsReadOnly && + (propertyBindingPoint.Value is not null || + (propertyBindingPoint.HasNewValue && !(propertyBindingPoint.Value is null && isNonNullableValueType)))) { property.SetValue(instance, propertyBindingPoint.Value); } From f9ac928b4e8044d5d99de2a39111b278add7f807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Thu, 19 Mar 2026 14:20:07 +0100 Subject: [PATCH 08/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index 4f7f3be454e2e3..59574bb25ef43a 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -1044,12 +1044,13 @@ private void EmitBindingLogicForComplexMember( return; } - // Truly set-only property (no getter at all): initialize to an appropriate default value - // since we can't read the current value. For value types, use a constructor call to match - // Activator.CreateInstance behavior (which honors user-defined parameterless ctors). - string initializer = $"new {effectiveMemberTypeFQN}();"; + // Truly set-only property (no getter at all): declare a temp for the value so that + // the binding logic can initialize it appropriately (for value types, using a + // constructor call to match Activator.CreateInstance behavior, which honors + // user-defined parameterless constructors). + initKind = InitializationKind.Declaration; - _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier} = {initializer};"); + _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier};"); } else if (memberType is NullableSpec) { From ac1b07b8a943e91cbe3a8a1e3988a2182d06fe57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jiri=20Cincura=20=E2=86=B9?= Date: Thu, 19 Mar 2026 14:31:52 +0100 Subject: [PATCH 09/14] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../gen/Emitter/CoreBindingHelpers.cs | 2 -- .../tests/Common/ConfigurationBinderTests.cs | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs index 59574bb25ef43a..9315f01b98c40c 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/gen/Emitter/CoreBindingHelpers.cs @@ -1049,8 +1049,6 @@ private void EmitBindingLogicForComplexMember( // constructor call to match Activator.CreateInstance behavior, which honors // user-defined parameterless constructors). initKind = InitializationKind.Declaration; - - _writer.WriteLine($"{effectiveMemberTypeFQN} {tempIdentifier};"); } else if (memberType is NullableSpec) { diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs index a302eff91b7bb0..4a3d06224a3584 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs @@ -1432,6 +1432,21 @@ public void BindsSetOnlyComplexProperties() Assert.Equal("Test", options.GetComplex().A); } + [Fact] + public void BindsSetOnlyComplexStructProperties() + { + var dic = new Dictionary + { + {"Complex:A", "Test"}, + }; + var configurationBuilder = new ConfigurationBuilder(); + configurationBuilder.AddInMemoryCollection(dic); + var config = configurationBuilder.Build(); + + var options = config.Get(); + Assert.Equal("Test", options.GetComplex().A); + } + [Fact] public void CanBindRecordOptions() { @@ -1994,6 +2009,26 @@ public void PrivatePropertiesFromBaseClass_Bind() var test = new ClassOverridingVirtualProperty(); + private struct SetOnlyComplexStruct + { + public string A { get; set; } + } + + private sealed class StructSetOnlyPoco + { + private SetOnlyComplexStruct _complex; + + public SetOnlyComplexStruct Complex + { + set => _complex = value; + } + + public SetOnlyComplexStruct GetComplex() + { + return _complex; + } + } + #if BUILDING_SOURCE_GENERATOR_TESTS var ex = Assert.Throws(() => config.Bind(test, b => b.BindNonPublicProperties = true)); Assert.Contains("BinderOptions.BindNonPublicProperties", ex.ToString()); From 999cf5c7c4022b8a56778e3f07e4bc36233c14b6 Mon Sep 17 00:00:00 2001 From: Jiri Cincura Date: Thu, 19 Mar 2026 14:46:14 +0100 Subject: [PATCH 10/14] Fix build. --- .../ConfigurationBinderTests.TestClasses.cs | 20 +++++++++++++++++++ .../tests/Common/ConfigurationBinderTests.cs | 20 ------------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs index ac1797acbc4587..ca1f95878f8411 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs @@ -648,6 +648,26 @@ public class ComplexSetOnlyPoco public SimplePoco Complex { set => _complex = value; } } + private struct SetOnlyComplexStruct + { + public string A { get; set; } + } + + private sealed class StructSetOnlyPoco + { + private SetOnlyComplexStruct _complex; + + public SetOnlyComplexStruct Complex + { + set => _complex = value; + } + + public SetOnlyComplexStruct GetComplex() + { + return _complex; + } + } + public class SetOnlyWithTypeConversionPoco { public double TimeoutSeconds { set => Timeout = TimeSpan.FromSeconds(value); } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs index 4a3d06224a3584..8bc848d3bc993c 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs @@ -2009,26 +2009,6 @@ public void PrivatePropertiesFromBaseClass_Bind() var test = new ClassOverridingVirtualProperty(); - private struct SetOnlyComplexStruct - { - public string A { get; set; } - } - - private sealed class StructSetOnlyPoco - { - private SetOnlyComplexStruct _complex; - - public SetOnlyComplexStruct Complex - { - set => _complex = value; - } - - public SetOnlyComplexStruct GetComplex() - { - return _complex; - } - } - #if BUILDING_SOURCE_GENERATOR_TESTS var ex = Assert.Throws(() => config.Bind(test, b => b.BindNonPublicProperties = true)); Assert.Contains("BinderOptions.BindNonPublicProperties", ex.ToString()); From db7fdd974b25b01bc88251485faa1d81b813e65d Mon Sep 17 00:00:00 2001 From: Jiri Cincura Date: Fri, 20 Mar 2026 10:06:06 +0100 Subject: [PATCH 11/14] Fix accessibility modifiers. --- .../tests/Common/ConfigurationBinderTests.TestClasses.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs index ca1f95878f8411..e8cf9b43325402 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs @@ -648,12 +648,12 @@ public class ComplexSetOnlyPoco public SimplePoco Complex { set => _complex = value; } } - private struct SetOnlyComplexStruct + public struct SetOnlyComplexStruct { public string A { get; set; } } - private sealed class StructSetOnlyPoco + public class StructSetOnlyPoco { private SetOnlyComplexStruct _complex; From 9f06f7afc85d5c2f62c67a3d1f4346c120a1d273 Mon Sep 17 00:00:00 2001 From: Jiri Cincura Date: Fri, 20 Mar 2026 13:33:02 +0100 Subject: [PATCH 12/14] Add test. --- .../Common/ConfigurationBinderTests.TestClasses.cs | 7 +++++++ .../tests/Common/ConfigurationBinderTests.cs | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs index e8cf9b43325402..758f08b1669832 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs @@ -674,6 +674,13 @@ public class SetOnlyWithTypeConversionPoco public TimeSpan Timeout { get; private set; } } + public class SetOnlyValueTypePoco + { + private bool _countSet; + public bool CountSet => _countSet; + public int Count { set => _countSet = true; } + } + public interface ISomeInterface { } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs index 8bc848d3bc993c..843d5a01e3c714 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs @@ -1447,6 +1447,16 @@ public void BindsSetOnlyComplexStructProperties() Assert.Equal("Test", options.GetComplex().A); } + [Fact] + public void SetOnlyNonNullableValueType_WithEmptyConfig_DoesNotCallSetter() + { + var dic = new Dictionary { { "Count", "" } }; + var config = new ConfigurationBuilder().AddInMemoryCollection(dic).Build(); + + var options = config.Get(); + Assert.False(options.CountSet); + } + [Fact] public void CanBindRecordOptions() { From bcf82a1d6bd7b34b91a55e5e21f8ea33698118ed Mon Sep 17 00:00:00 2001 From: Jiri Cincura Date: Fri, 20 Mar 2026 13:36:39 +0100 Subject: [PATCH 13/14] Add more tests. --- .../ConfigurationBinderTests.TestClasses.cs | 11 ++++- .../tests/Common/ConfigurationBinderTests.cs | 49 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs index 758f08b1669832..19d7d8e7b5b530 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.cs @@ -677,8 +677,17 @@ public class SetOnlyWithTypeConversionPoco public class SetOnlyValueTypePoco { private bool _countSet; + private int _count; + public int Count { set { _countSet = true; _count = value; } } public bool CountSet => _countSet; - public int Count { set => _countSet = true; } + public int GetCount() => _count; + } + + public class SetOnlyArrayPoco + { + private string[] _items; + public string[] GetItems() => _items; + public string[] Items { set => _items = value; } } public interface ISomeInterface diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs index 843d5a01e3c714..09d7a02a1ea041 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs @@ -1448,11 +1448,56 @@ public void BindsSetOnlyComplexStructProperties() } [Fact] - public void SetOnlyNonNullableValueType_WithEmptyConfig_DoesNotCallSetter() + public void BindsSetOnlyArrayProperty_WithElements() { - var dic = new Dictionary { { "Count", "" } }; + var dic = new Dictionary + { + {"Items:0", "a"}, + {"Items:1", "b"}, + }; + var config = new ConfigurationBuilder().AddInMemoryCollection(dic).Build(); + + var options = config.Get(); + Assert.Equal(new[] { "a", "b" }, options.GetItems()); + } + + [Fact] + public void BindsSetOnlyArrayProperty_WithEmptyString() + { + var dic = new Dictionary { { "Items", "" } }; var config = new ConfigurationBuilder().AddInMemoryCollection(dic).Build(); + var options = config.Get(); + Assert.NotNull(options.GetItems()); + Assert.Empty(options.GetItems()); + } + + [Fact] + public void BindsSetOnlyNonNullableValueType_WithValidConfig() + { + var dic = new Dictionary { { "Count", "42" } }; + var config = new ConfigurationBuilder().AddInMemoryCollection(dic).Build(); + + var options = config.Get(); + Assert.Equal(42, options.GetCount()); + } + + [Fact] + public void BindsSetOnlyProperties_ViaBind() + { + var dic = new Dictionary { { "SetOnly", "hello" } }; + var config = new ConfigurationBuilder().AddInMemoryCollection(dic).Build(); + + var target = new SetOnlyPoco(); + config.Bind(target); + Assert.True(target.SetOnlyCalled); + } + + [Fact] + public void SetOnlyNonNullableValueType_WithMissingConfig_DoesNotCallSetter() + { + var config = new ConfigurationBuilder().Build(); + var options = config.Get(); Assert.False(options.CountSet); } From 5e45e59f554b63569b96af5770aaa40932caf015 Mon Sep 17 00:00:00 2001 From: Jiri Cincura Date: Fri, 20 Mar 2026 19:06:56 +0100 Subject: [PATCH 14/14] More work. --- .../src/ConfigurationBinder.cs | 7 +------ .../tests/Common/ConfigurationBinderTests.cs | 9 --------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs index a0918f9e2f1e71..bf257498b5f669 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs @@ -329,12 +329,7 @@ private static void BindProperty(PropertyInfo property, object instance, IConfig // As example, when binding a property which not having a configuration entry matching this property and the getter can initialize the Value. // It is important to call the property setter as the setters can have a logic adjusting the Value. // Otherwise, if the HasNewValue set to true, it means that the property setter should be called anyway as encountering a new value. - bool isNonNullableValueType = property.PropertyType.IsValueType && - Nullable.GetUnderlyingType(property.PropertyType) is null; - - if (!propertyBindingPoint.IsReadOnly && - (propertyBindingPoint.Value is not null || - (propertyBindingPoint.HasNewValue && !(propertyBindingPoint.Value is null && isNonNullableValueType)))) + if (!propertyBindingPoint.IsReadOnly && (propertyBindingPoint.Value is not null || propertyBindingPoint.HasNewValue)) { property.SetValue(instance, propertyBindingPoint.Value); } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs index 09d7a02a1ea041..de8b432c7d2758 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.cs @@ -1493,15 +1493,6 @@ public void BindsSetOnlyProperties_ViaBind() Assert.True(target.SetOnlyCalled); } - [Fact] - public void SetOnlyNonNullableValueType_WithMissingConfig_DoesNotCallSetter() - { - var config = new ConfigurationBuilder().Build(); - - var options = config.Get(); - Assert.False(options.CountSet); - } - [Fact] public void CanBindRecordOptions() {