From abc73f56ed91b8b7c441d967a7a210792d221c99 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 14 Jul 2026 21:29:46 +0200 Subject: [PATCH 01/24] Add TypeConverter, IFormattable and span parse/format to strong types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a TypeConverter, ConfigurationBinder cannot turn "5" into a strong type. It does not fail: it treats the wrapper as a nested object graph and leaves the property at its default, so `Positive MaxRetries` silently binds to 1 whatever appsettings.json says — and the offset encoding that keeps `default` satisfying the invariant is what makes the wrong value look plausible. Strong types were unusable in an options class. Composite formatting has the same shape of failure: `$"{price:C}"` on a type that is not IFormattable compiles, never throws, and drops the specifier and the culture on the floor. The numeric wrappers get IFormattable/ISpanFormattable/ISpanParsable by pure delegation — INumber already guarantees all of them on T — so the four wrappers are covered by one generator change. The span interfaces buy little on allocation (the implicit operator already routes to the BCL's fast paths, and Create(int.Parse(slice)) was already zero-alloc); they earn their place by letting callers pass wrappers to generic code constrained on them. Co-Authored-By: Claude Opus 4.8 --- .../NumericWrapperGenerator.cs | 39 +++-- .../ComponentModel/OptionsBindingTests.cs | 145 ++++++++++++++++ .../ComponentModel/TypeConverterTests.cs | 114 +++++++++++++ .../Numbers/NumericFormattingTests.cs | 159 ++++++++++++++++++ .../StrongTypes.Tests.csproj | 3 + .../ComponentModel/ParsableTypeConverter.cs | 35 ++++ .../ComponentModel/StrongTypeConverter.cs | 34 ++++ src/StrongTypes/Digits/Digit.cs | 2 + src/StrongTypes/Emails/Email.cs | 2 + src/StrongTypes/Numbers/Negative.cs | 2 + src/StrongTypes/Numbers/NonNegative.cs | 2 + src/StrongTypes/Numbers/NonPositive.cs | 2 + src/StrongTypes/Numbers/Positive.cs | 2 + src/StrongTypes/Strings/NonEmptyString.cs | 2 + 14 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs create mode 100644 src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs create mode 100644 src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs create mode 100644 src/StrongTypes/ComponentModel/ParsableTypeConverter.cs create mode 100644 src/StrongTypes/ComponentModel/StrongTypeConverter.cs diff --git a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs index ff689cc9..68f9cd4a 100644 --- a/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs +++ b/src/StrongTypes.SourceGenerators/NumericWrapperGenerator.cs @@ -178,7 +178,10 @@ public static string EmitType(Model m) sb.Append(" global::System.IComparable<").Append(self).AppendLine(">,"); sb.Append(" global::System.IComparable<").Append(t).AppendLine(">,"); sb.AppendLine(" global::System.IComparable,"); - sb.Append(" global::System.IParsable<").Append(self).AppendLine(">"); + sb.AppendLine(" global::System.IFormattable,"); + sb.AppendLine(" global::System.ISpanFormattable,"); + sb.Append(" global::System.IParsable<").Append(self).AppendLine(">,"); + sb.Append(" global::System.ISpanParsable<").Append(self).AppendLine(">"); foreach (var c in m.ConstraintClauses) sb.Append(" ").AppendLine(c); @@ -199,18 +202,13 @@ public static string EmitType(Model m) sb.Append(" => Create(").Append(t).AppendLine(".Parse(s, provider));"); sb.AppendLine(); - sb.Append(" public static bool TryParse(string? s, global::System.IFormatProvider? provider, out ").Append(self).AppendLine(" result)"); - sb.AppendLine(" {"); - sb.Append(" if (").Append(t).AppendLine(".TryParse(s, provider, out var underlying) && TryCreate(underlying) is { } parsed)"); - sb.AppendLine(" {"); - sb.AppendLine(" result = parsed;"); - sb.AppendLine(" return true;"); - sb.AppendLine(" }"); - sb.AppendLine(" result = default;"); - sb.AppendLine(" return false;"); - sb.AppendLine(" }"); + sb.Append(" public static ").Append(self).AppendLine(" Parse(global::System.ReadOnlySpan s, global::System.IFormatProvider? provider)"); + sb.Append(" => Create(").Append(t).AppendLine(".Parse(s, provider));"); sb.AppendLine(); + EmitTryParse(sb, self, t, "string? s"); + EmitTryParse(sb, self, t, "global::System.ReadOnlySpan s"); + sb.AppendLine(" public override int GetHashCode() => Value.GetHashCode();"); sb.AppendLine(); @@ -256,11 +254,30 @@ public static string EmitType(Model m) sb.AppendLine(); sb.AppendLine(" public override string ToString() => Value.ToString() ?? string.Empty;"); + sb.AppendLine(" public string ToString(string? format, global::System.IFormatProvider? provider) => Value.ToString(format, provider);"); + sb.AppendLine(); + sb.AppendLine(" public bool TryFormat(global::System.Span destination, out int charsWritten, global::System.ReadOnlySpan format, global::System.IFormatProvider? provider)"); + sb.AppendLine(" => Value.TryFormat(destination, out charsWritten, format, provider);"); sb.AppendLine("}"); return sb.ToString(); } + private static void EmitTryParse(StringBuilder sb, string self, string underlyingType, string parameter) + { + sb.Append(" public static bool TryParse(").Append(parameter).Append(", global::System.IFormatProvider? provider, out ").Append(self).AppendLine(" result)"); + sb.AppendLine(" {"); + sb.Append(" if (").Append(underlyingType).AppendLine(".TryParse(s, provider, out var underlying) && TryCreate(underlying) is { } parsed)"); + sb.AppendLine(" {"); + sb.AppendLine(" result = parsed;"); + sb.AppendLine(" return true;"); + sb.AppendLine(" }"); + sb.AppendLine(" result = default;"); + sb.AppendLine(" return false;"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + public static string EmitExtensions(Model m) { var sb = new StringBuilder(); diff --git a/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs b/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs new file mode 100644 index 00000000..a2a58bef --- /dev/null +++ b/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using FsCheck.Xunit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace StrongTypes.Tests; + +/// +/// Binding an options class is what the TypeConverter exists for: without one, +/// ConfigurationBinder finds no way to turn "5" into a strong type, treats +/// the wrapper as a nested object graph, and silently leaves the property at its default — +/// no exception, just a wrong value. These tests pin the whole path end to end. +/// +public class OptionsBindingTests +{ + private sealed class RetryOptions + { + public Positive MaxRetries { get; set; } + public NonNegative InitialDelaySeconds { get; set; } + public NonEmptyString? Name { get; set; } + public Email? Contact { get; set; } + public Digit Tier { get; set; } + public Positive? OptionalLimit { get; set; } + } + + private static IConfiguration Config(params (string Key, string Value)[] settings) + { + var values = new Dictionary(); + foreach (var (key, value) in settings) + { + values[key] = value; + } + + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + private static RetryOptions BindViaOptions(IConfiguration configuration) + { + var services = new ServiceCollection(); + services.AddOptions().Bind(configuration.GetSection("Retry")); + return services.BuildServiceProvider().GetRequiredService>().Value; + } + + // ── The scenario the converter unlocks ────────────────────────────── + + [Fact] + public void EveryStrongTypeBindsFromConfiguration() + { + var options = BindViaOptions(Config( + ("Retry:MaxRetries", "5"), + ("Retry:InitialDelaySeconds", "0"), + ("Retry:Name", "checkout"), + ("Retry:Contact", "ops@example.com"), + ("Retry:Tier", "7"), + ("Retry:OptionalLimit", "99"))); + + Assert.Equal(5, options.MaxRetries.Value); + Assert.Equal(0, options.InitialDelaySeconds.Value); + Assert.Equal("checkout", options.Name?.Value); + Assert.Equal("ops@example.com", options.Contact?.Address); + Assert.Equal(7, options.Tier.Value); + Assert.Equal(99, options.OptionalLimit?.Value); + } + + /// + /// The regression that motivated the converter. default(Positive<int>) is a + /// plausible-looking 1 — the offset encoding that keeps the invariant true for + /// default is exactly what makes a dropped config value impossible to spot — so + /// assert the bound value differs from the default rather than merely that it is set. + /// + [Property] + public void BoundValueComesFromConfiguration_NotTheDefault(int configured) + { + if (configured <= 0 || configured == default(Positive).Value) return; + + var options = BindViaOptions(Config(("Retry:MaxRetries", configured.ToString()))); + + Assert.Equal(configured, options.MaxRetries.Value); + Assert.NotEqual(default(Positive).Value, options.MaxRetries.Value); + } + + [Fact] + public void AbsentKeyLeavesTheDefault() + { + var options = BindViaOptions(Config(("Retry:Name", "checkout"))); + + Assert.Equal(default(Positive).Value, options.MaxRetries.Value); + Assert.Null(options.OptionalLimit); + } + + // ── Invalid configuration fails, and says why ─────────────────────── + + [Theory] + [InlineData("Retry:MaxRetries", "-5", "must be positive")] + [InlineData("Retry:MaxRetries", "0", "must be positive")] + [InlineData("Retry:InitialDelaySeconds", "-1", "must be non-negative")] + [InlineData("Retry:Name", " ", "whitespace")] + [InlineData("Retry:Contact", "not-an-email", "valid email")] + [InlineData("Retry:Tier", "42", "single decimal digit")] + public void ValueBreakingTheInvariant_ThrowsAndNamesTheReason(string key, string value, string expectedReason) + { + var exception = Assert.Throws(() => BindViaOptions(Config((key, value)))); + + // The binder's own message carries the config path and the offending value; the + // invariant's reason survives as the inner exception the wrapper threw. + Assert.Contains(key, exception.Message, StringComparison.Ordinal); + Assert.Contains(value, exception.Message, StringComparison.Ordinal); + + var inner = Assert.IsType(exception.InnerException); + Assert.Contains(expectedReason, inner.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("Retry:MaxRetries", "not-a-number")] + [InlineData("Retry:Tier", "not-a-digit")] + public void ValueInTheWrongFormat_Throws(string key, string value) + { + var exception = Assert.Throws(() => BindViaOptions(Config((key, value)))); + Assert.Contains(key, exception.Message, StringComparison.Ordinal); + } + + /// A nullable wrapper still enforces the invariant when a value is present. + [Fact] + public void NullableWrapper_InvalidValue_Throws() => + Assert.Throws(() => BindViaOptions(Config(("Retry:OptionalLimit", "-1")))); + + /// + /// Binding is lazy: the failure surfaces on first IOptions.Value, not at + /// BuildServiceProvider. Callers wanting startup failure add ValidateOnStart. + /// + [Fact] + public void InvalidValue_DoesNotThrowUntilOptionsAreRead() + { + var services = new ServiceCollection(); + services.AddOptions().Bind(Config(("Retry:MaxRetries", "-5")).GetSection("Retry")); + + var provider = services.BuildServiceProvider(); + var accessor = provider.GetRequiredService>(); + + Assert.Throws(() => accessor.Value); + } +} diff --git a/src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs b/src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs new file mode 100644 index 00000000..4dc2976c --- /dev/null +++ b/src/StrongTypes.Tests/ComponentModel/TypeConverterTests.cs @@ -0,0 +1,114 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using FsCheck.Xunit; +using Xunit; + +namespace StrongTypes.Tests; + +public class TypeConverterTests +{ + public static TheoryData ConvertibleTypes => new() + { + { typeof(NonEmptyString), "hello" }, + { typeof(Email), "ops@example.com" }, + { typeof(Digit), "7" }, + { typeof(Positive), "42" }, + { typeof(NonNegative), "0" }, + { typeof(Negative), "-42" }, + { typeof(NonPositive), "0" }, + }; + + /// Anything reaching a strong type through — configuration binding, WPF/WinForms bindings, designers — starts here. + [Theory] + [MemberData(nameof(ConvertibleTypes))] + public void TypeDescriptor_ResolvesAConverterThatAcceptsStrings(Type type, string wire) + { + var converter = TypeDescriptor.GetConverter(type); + + Assert.True(converter.CanConvertFrom(typeof(string))); + Assert.True(converter.CanConvertTo(typeof(string))); + + var converted = converter.ConvertFromInvariantString(wire); + + Assert.NotNull(converted); + Assert.IsType(type, converted); + Assert.Equal(wire, converter.ConvertToInvariantString(converted)); + } + + [Theory] + [MemberData(nameof(ConvertibleTypes))] + public void Converter_RejectsNonStringSources(Type type, string wire) + { + _ = wire; + var converter = TypeDescriptor.GetConverter(type); + + Assert.False(converter.CanConvertFrom(typeof(Guid))); + Assert.Throws(() => converter.ConvertFrom(Guid.NewGuid())); + } + + /// A broken invariant surfaces as the wrapper's own , not a generic conversion failure — that message is what the caller reports. + [Theory] + [InlineData(typeof(Positive), "-5")] + [InlineData(typeof(NonNegative), "-1")] + [InlineData(typeof(NonEmptyString), " ")] + [InlineData(typeof(Email), "not-an-email")] + [InlineData(typeof(Digit), "42")] + public void Converter_InvariantBreach_ThrowsArgumentException(Type type, string wire) => + Assert.Throws(() => TypeDescriptor.GetConverter(type).ConvertFromInvariantString(wire)); + + [Fact] + public void Converter_MalformedNumber_ThrowsFormatException() => + Assert.Throws(() => TypeDescriptor.GetConverter(typeof(Positive)).ConvertFromInvariantString("not-a-number")); + + // ── Culture ───────────────────────────────────────────────────────── + + /// The converter parses in the culture it is handed, so a decimal comma is a decimal point's equal. + [Fact] + public void Converter_ParsesInTheSuppliedCulture() + { + var converter = TypeDescriptor.GetConverter(typeof(Positive)); + var german = CultureInfo.GetCultureInfo("de-DE"); + + Assert.Equal(1234.5m, ((Positive)converter.ConvertFrom(null, german, "1234,5")!).Value); + Assert.Equal(1234.5m, ((Positive)converter.ConvertFrom(null, CultureInfo.InvariantCulture, "1234.5")!).Value); + } + + /// ConvertTo must format in the same culture ConvertFrom parses in, or the pair does not round-trip. + [Theory] + [InlineData("de-DE")] + [InlineData("en-US")] + [InlineData("")] + public void Converter_RoundTripsThroughOneCulture(string cultureName) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + var converter = TypeDescriptor.GetConverter(typeof(Positive)); + var original = Positive.Create(1234.5m); + + var text = converter.ConvertTo(null, culture, original, typeof(string)); + var roundTripped = converter.ConvertFrom(null, culture, text!); + + Assert.Equal(original, roundTripped); + } + + [Property] + public void Converter_RoundTripsEveryPositiveInt(int value) + { + if (value <= 0) return; + + var converter = TypeDescriptor.GetConverter(typeof(Positive)); + var text = converter.ConvertToInvariantString(Positive.Create(value)); + + Assert.Equal(Positive.Create(value), converter.ConvertFromInvariantString(text!)); + } + + // ── StrongTypeConverter's own contract ────────────────────────────── + + [Fact] + public void StrongTypeConverter_NonParsableType_Throws() => + Assert.Throws(() => new StrongTypeConverter(typeof(object))); + + [Fact] + public void StrongTypeConverter_NullType_Throws() => + Assert.Throws(() => new StrongTypeConverter(null!)); +} diff --git a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs new file mode 100644 index 00000000..5d39defc --- /dev/null +++ b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Globalization; +using FsCheck.Xunit; +using Xunit; + +namespace StrongTypes.Tests; + +/// +/// Composite formatting silently ignores a format specifier on a type that is not +/// $"{price:C}" compiles, never throws, and prints the +/// unformatted number. These tests pin the specifier and the culture actually reaching the +/// underlying value, for every numeric wrapper the generator emits. +/// +public class NumericFormattingTests +{ + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + private static readonly CultureInfo American = CultureInfo.GetCultureInfo("en-US"); + + // ── IFormattable ──────────────────────────────────────────────────── + + [Fact] + public void FormatSpecifier_IsHonouredInInterpolation() + { + var price = Positive.Create(1234.5m); + + Assert.Equal("1,234.50", string.Format(American, "{0:N2}", price)); + Assert.Equal("$1,234.50", string.Format(American, "{0:C}", price)); + Assert.Equal("1.234,50", string.Format(German, "{0:N2}", price)); + } + + [Fact] + public void FormatProvider_IsHonouredWithoutASpecifier() => + Assert.Equal("1234,5", string.Format(German, "{0}", Positive.Create(1234.5m))); + + [Theory] + [InlineData("N2", "1,234.50")] + [InlineData("C", "$1,234.50")] + [InlineData("F0", "1235")] + [InlineData("E2", "1.23E+003")] + [InlineData(null, "1234.5")] + public void ToString_MatchesTheUnderlyingValue(string? format, string expected) + { + var price = Positive.Create(1234.5m); + + Assert.Equal(expected, price.ToString(format, American)); + Assert.Equal(price.Value.ToString(format, American), price.ToString(format, American)); + } + + /// The wrapper must never disagree with the value it wraps, for any format the underlying type accepts. + [Property] + public void ToString_NeverDivergesFromTheUnderlyingValue(int value) + { + if (value <= 0) return; + + var positive = Positive.Create(value); + foreach (var format in new[] { "D", "N0", "X", "G", null }) + { + Assert.Equal(value.ToString(format, American), positive.ToString(format, American)); + } + } + + [Fact] + public void EveryWrapper_IsFormattable() + { + Assert.Equal("1,234.50", string.Format(American, "{0:N2}", Positive.Create(1234.5m))); + Assert.Equal("1,234.50", string.Format(American, "{0:N2}", NonNegative.Create(1234.5m))); + Assert.Equal("-1,234.50", string.Format(American, "{0:N2}", Negative.Create(-1234.5m))); + Assert.Equal("-1,234.50", string.Format(American, "{0:N2}", NonPositive.Create(-1234.5m))); + } + + // ── ISpanFormattable ──────────────────────────────────────────────── + + [Fact] + public void TryFormat_WritesIntoTheDestination() + { + Span destination = stackalloc char[32]; + + Assert.True(Positive.Create(1234.5m).TryFormat(destination, out var written, "N2", American)); + Assert.Equal("1,234.50", destination[..written].ToString()); + } + + [Fact] + public void TryFormat_DestinationTooShort_ReturnsFalse() + { + Span destination = stackalloc char[2]; + + Assert.False(Positive.Create(1234.5m).TryFormat(destination, out var written, "N2", American)); + Assert.Equal(0, written); + } + + // ── ISpanParsable ─────────────────────────────────────────────────── + + [Fact] + public void Parse_ReadsASliceWithoutMaterialisingIt() + { + const string line = "sku-1,42,widget"; + + Assert.Equal(42, Positive.Parse(line.AsSpan(6, 2), CultureInfo.InvariantCulture).Value); + } + + [Property] + public void TryParse_Span_MatchesTryParse_String(int value) + { + var text = value.ToString(CultureInfo.InvariantCulture); + + var fromString = Positive.TryParse(text, CultureInfo.InvariantCulture, out var parsedFromString); + var fromSpan = Positive.TryParse(text.AsSpan(), CultureInfo.InvariantCulture, out var parsedFromSpan); + + Assert.Equal(fromString, fromSpan); + Assert.Equal(parsedFromString, parsedFromSpan); + Assert.Equal(value > 0, fromSpan); + } + + [Fact] + public void Parse_Span_StillEnforcesTheInvariant() => + Assert.Throws(() => Positive.Parse("-5".AsSpan(), CultureInfo.InvariantCulture)); + + [Fact] + public void TryParse_Span_BreachedInvariant_ReturnsFalse() + { + Assert.False(Positive.TryParse("-5".AsSpan(), CultureInfo.InvariantCulture, out var result)); + Assert.Equal(default, result); + } + + // ── Generic constraints ───────────────────────────────────────────── + // + // The reason the span interfaces earn their place: without them a wrapper + // cannot be passed to generic code constrained on them, and no amount of + // reaching for .Value at the call site fixes that for the caller. + + private static string Render(T value, string format) where T : IFormattable => + value.ToString(format, American); + + private static T Read(ReadOnlySpan text) where T : ISpanParsable => + T.Parse(text, CultureInfo.InvariantCulture); + + private static string RenderIntoBuffer(T value, string format) where T : ISpanFormattable + { + Span destination = stackalloc char[64]; + return value.TryFormat(destination, out var written, format, American) ? destination[..written].ToString() : ""; + } + + [Fact] + public void SatisfiesTheIFormattableConstraint() => + Assert.Equal("$1,234.50", Render(Positive.Create(1234.5m), "C")); + + [Fact] + public void SatisfiesTheISpanFormattableConstraint() => + Assert.Equal("1,234.50", RenderIntoBuffer(Positive.Create(1234.5m), "N2")); + + [Fact] + public void SatisfiesTheISpanParsableConstraint() + { + Assert.Equal(42, Read>("42").Value); + Assert.Equal(0, Read>("0").Value); + Assert.Equal(-42, Read>("-42").Value); + Assert.Equal(-42, Read>("-42").Value); + } +} diff --git a/src/StrongTypes.Tests/StrongTypes.Tests.csproj b/src/StrongTypes.Tests/StrongTypes.Tests.csproj index d2d42542..2b11725a 100644 --- a/src/StrongTypes.Tests/StrongTypes.Tests.csproj +++ b/src/StrongTypes.Tests/StrongTypes.Tests.csproj @@ -12,6 +12,9 @@ + + + diff --git a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs new file mode 100644 index 00000000..7080d29f --- /dev/null +++ b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs @@ -0,0 +1,35 @@ +#nullable enable + +using System; +using System.ComponentModel; +using System.Globalization; + +namespace StrongTypes; + +/// Converts between and via , honouring the culture it is handed. +/// A strong type that implements . +/// Invalid input surfaces as whatever T.Parse throws — for a Kalicz.StrongTypes wrapper that is the naming the broken invariant, which callers such as ConfigurationBinder surface as the inner exception. Apply to an open generic via instead. +public sealed class ParsableTypeConverter : TypeConverter + where T : IParsable +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => + destinationType == typeof(string) || base.CanConvertTo(context, destinationType); + + /// is a string that breaks 's invariant. + /// is a string that is not in 's format. + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => + value is string s ? T.Parse(s, culture) : base.ConvertFrom(context, culture, value); + + public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) + { + if (destinationType != typeof(string) || value is not T parsed) + return base.ConvertTo(context, culture, value, destinationType); + + // Must round-trip ConvertFrom, which parses in `culture` — formatting in + // any other culture would re-parse to a different number. + return parsed is IFormattable formattable ? formattable.ToString(null, culture) : parsed.ToString(); + } +} diff --git a/src/StrongTypes/ComponentModel/StrongTypeConverter.cs b/src/StrongTypes/ComponentModel/StrongTypeConverter.cs new file mode 100644 index 00000000..934d68ff --- /dev/null +++ b/src/StrongTypes/ComponentModel/StrongTypeConverter.cs @@ -0,0 +1,34 @@ +#nullable enable + +using System; +using System.ComponentModel; +using System.Globalization; + +namespace StrongTypes; + +/// Converts between and a strong type that implements , for generic types that cannot name a closed in an attribute argument. +/// Behaves exactly like ; a non-generic type should name that directly instead. +public sealed class StrongTypeConverter : TypeConverter +{ + private readonly TypeConverter _inner; + + /// The closed strong type to convert. supplies this from the type carrying the attribute. + /// does not implement . + public StrongTypeConverter(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + if (!typeof(IParsable<>).MakeGenericType(type).IsAssignableFrom(type)) + throw new ArgumentException($"{type} must implement IParsable<{type.Name}> to be converted from a string.", nameof(type)); + + _inner = (TypeConverter)Activator.CreateInstance(typeof(ParsableTypeConverter<>).MakeGenericType(type))!; + } + + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => _inner.CanConvertFrom(context, sourceType); + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => _inner.CanConvertTo(context, destinationType); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => _inner.ConvertFrom(context, culture, value); + + public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => _inner.ConvertTo(context, culture, value, destinationType); +} diff --git a/src/StrongTypes/Digits/Digit.cs b/src/StrongTypes/Digits/Digit.cs index e152f976..27ba923f 100644 --- a/src/StrongTypes/Digits/Digit.cs +++ b/src/StrongTypes/Digits/Digit.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; @@ -6,6 +7,7 @@ namespace StrongTypes; /// A decimal digit in the range 09, parsed from a single character. /// A character is accepted when returns true, which includes non-ASCII Unicode decimal digits whose numeric value folds into 0–9. default(Digit) represents 0. +[TypeConverter(typeof(ParsableTypeConverter))] public readonly struct Digit : IEquatable, IEquatable, diff --git a/src/StrongTypes/Emails/Email.cs b/src/StrongTypes/Emails/Email.cs index 88b240b7..f7500223 100644 --- a/src/StrongTypes/Emails/Email.cs +++ b/src/StrongTypes/Emails/Email.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Net.Mail; @@ -9,6 +10,7 @@ namespace StrongTypes; /// An email address validated by and capped at the RFC 5321 deliverable length of 254 characters. /// The property hands the caller a directly so it can be passed straight into APIs that take one (mailers, validators). The wire form on JSON, EF Core columns, and route arguments is the underlying address string. [JsonConverter(typeof(EmailJsonConverter))] +[TypeConverter(typeof(ParsableTypeConverter))] public sealed class Email : IEquatable, IEquatable, diff --git a/src/StrongTypes/Numbers/Negative.cs b/src/StrongTypes/Numbers/Negative.cs index d5266abd..bb428b0f 100644 --- a/src/StrongTypes/Numbers/Negative.cs +++ b/src/StrongTypes/Numbers/Negative.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(Negative<T>) wraps -T.One and satisfies the invariant. [NumericWrapper(InvariantDescription = "negative", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct Negative where T : INumber { diff --git a/src/StrongTypes/Numbers/NonNegative.cs b/src/StrongTypes/Numbers/NonNegative.cs index 541392c9..ccc8f192 100644 --- a/src/StrongTypes/Numbers/NonNegative.cs +++ b/src/StrongTypes/Numbers/NonNegative.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(NonNegative<T>) wraps T.Zero and satisfies the invariant. [NumericWrapper(InvariantDescription = "non-negative", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct NonNegative where T : INumber { diff --git a/src/StrongTypes/Numbers/NonPositive.cs b/src/StrongTypes/Numbers/NonPositive.cs index 4ca370d2..51a99e10 100644 --- a/src/StrongTypes/Numbers/NonPositive.cs +++ b/src/StrongTypes/Numbers/NonPositive.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(NonPositive<T>) wraps T.Zero and satisfies the invariant. [NumericWrapper(InvariantDescription = "non-positive", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct NonPositive where T : INumber { diff --git a/src/StrongTypes/Numbers/Positive.cs b/src/StrongTypes/Numbers/Positive.cs index db4839bb..4e1fad2b 100644 --- a/src/StrongTypes/Numbers/Positive.cs +++ b/src/StrongTypes/Numbers/Positive.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(Positive<T>) wraps T.One and satisfies the invariant. [NumericWrapper(InvariantDescription = "positive", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct Positive where T : INumber { diff --git a/src/StrongTypes/Strings/NonEmptyString.cs b/src/StrongTypes/Strings/NonEmptyString.cs index 5207b6b2..39fc29ec 100644 --- a/src/StrongTypes/Strings/NonEmptyString.cs +++ b/src/StrongTypes/Strings/NonEmptyString.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Globalization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// A string guaranteed to be non-null, non-empty, and not consisting solely of whitespace. /// Comparison uses the current culture (it delegates to ). Exposes Count and a char indexer for parity with ; Count in particular makes the BCL [MaxLength] attribute work without a custom shim. [JsonConverter(typeof(NonEmptyStringJsonConverter))] +[TypeConverter(typeof(ParsableTypeConverter))] public sealed class NonEmptyString : IEquatable, IEquatable, From 8a2642947d25ed3c2ef2c8acf06fcf45b97be213 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 14 Jul 2026 21:33:18 +0200 Subject: [PATCH 02/24] Document config binding and formatting in the skill Co-Authored-By: Claude Opus 4.8 --- Skill/SKILL.md | 4 +- Skill/references/configuration.md | 82 +++++++++++++++++++ Skill/references/numeric.md | 45 ++++++++++ .../ComponentModel/OptionsBindingTests.cs | 16 +--- .../Numbers/NumericFormattingTests.cs | 12 +-- .../ComponentModel/ParsableTypeConverter.cs | 5 +- 6 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 Skill/references/configuration.md diff --git a/Skill/SKILL.md b/Skill/SKILL.md index fa8451a1..811ba384 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -9,7 +9,8 @@ Library of focused C# value wrappers (`NonEmptyString`, `Positive`, `NonEmptyEnumerable`, …) and algebraic types (`Maybe`, `Result`) that push invariants into the type system. Every wrapper ships a `System.Text.Json` converter, so invalid input fails at -deserialization before any endpoint code runs. +deserialization before any endpoint code runs, and a `TypeConverter`, so +the same invariant validates `appsettings.json` as it binds. Per-type detail lives in `references/*.md` — load the relevant file on demand when about to write code against that surface. @@ -62,6 +63,7 @@ demand when about to write code against that surface. | Topic | Reference | | ------------------------------------------------------------- | ------------------------------- | | Enum extensions (`Enum.Parse`, `AllValues`, `AllFlagValues`, `GetFlags`) and `string?` parsers (`AsInt`, `AsGuid`, `AsEnum`, …) | `references/parsing.md` | +| Configuration / `IOptions` binding — zero setup, invariant doubles as validation, `ValidateOnStart()` | `references/configuration.md` | | `T?.Map`, `bool.MapTrue` / `MapFalse` | `references/map.md` | | `IEnumerable` extensions, `ReadOnlyList`, `Result` partition helpers | `references/collections.md` | | EF Core: `UseStrongTypes` value converters, interval column mapping, `.Unwrap()` LINQ marker | `references/efcore.md` | diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md new file mode 100644 index 00000000..863a7ca0 --- /dev/null +++ b/Skill/references/configuration.md @@ -0,0 +1,82 @@ +# Configuration and options binding + +Strong types bind from `IConfiguration` / `IOptions` with **no setup** — +every wrapper carries a `TypeConverter`, which is what `ConfigurationBinder` +uses to turn a config string into a typed value. Put the wrapper straight on +the options class: + +```csharp +public sealed class RetryOptions +{ + public Positive MaxRetries { get; set; } + public NonNegative InitialDelaySeconds { get; set; } + public NonEmptyString? Name { get; set; } + public Email? Contact { get; set; } + public Positive? OptionalLimit { get; set; } // absent key stays null +} + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Retry")) + .ValidateOnStart(); // see "Fail at startup" below +``` + +```json +{ + "Retry": { + "MaxRetries": 5, + "InitialDelaySeconds": 0, + "Name": "checkout", + "Contact": "ops@example.com" + } +} +``` + +## The invariant is the validation rule + +This is the reason to use a wrapper here rather than a plain `int`. The type +already says "must be positive", so a bad value is rejected without a +`[Range]` attribute or a custom `IValidateOptions`: + +``` +InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries' + to type 'StrongTypes.Positive`1[System.Int32]'. + ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') +``` + +The outer frame names the **config path** and the **offending value**; the +inner one names the **broken invariant**. Don't add a `[Range(1, int.MaxValue)]` +on top of a `Positive` — it is the same rule stated twice, and only one +of the two is enforced by the type. + +## Fail at startup, not on the first request + +Options binding is **lazy**. Without `ValidateOnStart()`, a bad value does not +stop the app: it starts, serves traffic, and throws on the first request that +reads `IOptions.Value` — surfacing as a 500 rather than a failed deploy. + +```csharp +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Retry")) + .ValidateOnStart(); // conversion failures now abort startup +``` + +`ValidateOnStart()` forces eager binding, so it catches conversion failures +even with **no** `IValidateOptions` registered. Always add it when options +hold strong types — otherwise the invariant buys you a later crash, not an +earlier one. + +## Things worth knowing + +- **A missing key leaves the default**, it does not throw. `default(Positive)` + is `1` and `default(NonNegative)` is `0` — both satisfy their invariant, so + a typo'd key is a silently valid default. Use `Positive?` when "not + configured" must be distinguishable from a configured value. +- **`ConfigurationBinder` parses with the invariant culture**, so `"1234.5"` is + a `Positive` of 1234.5 regardless of the host's locale. Config files + are not localized; don't write `1234,5`. +- **Nullable wrappers still enforce the invariant** when a value is present: + `OptionalLimit` may be absent, but `-1` is rejected. +- The same `TypeConverter` powers anything that goes through `TypeDescriptor` — + WinForms designers, `PropertyGrid`, and libraries doing generic + string↔object conversion. WPF users still call `this.UseStrongTypes()` + (see `references/wpf.md`). diff --git a/Skill/references/numeric.md b/Skill/references/numeric.md index 4ba7fa88..15673ea6 100644 --- a/Skill/references/numeric.md +++ b/Skill/references/numeric.md @@ -49,8 +49,53 @@ All four types have `AsX` / `ToX` extension methods available on any and matching `==`, `!=`, `<`, `<=`, `>`, `>=` operators on both sides — so `4.ToPositive() > 2` is just a comparison, no unwrap. - `GetHashCode`, `Equals(object?)`, `ToString()` delegating to the value. +- `IFormattable` / `ISpanFormattable` — format specifiers and cultures reach + the underlying value (see "Formatting" below). +- `IParsable` / `ISpanParsable` — `Parse` / `TryParse` from a + `string` or a `ReadOnlySpan`, enforcing the invariant on the way in. + This is also what lets ASP.NET Core bind a wrapper from `[FromQuery]` / + `[FromRoute]` without any package. - `System.Text.Json` converter via `[JsonConverter]` — serialises as the underlying primitive. +- `TypeConverter` via `[TypeConverter]` — binds from `appsettings.json` / + `IOptions`. See `references/configuration.md`. + +## Formatting + +Format specifiers and format providers pass straight through to the +underlying value, so a wrapper formats exactly like the number it wraps: + +```csharp +var price = 1234.5m.ToPositive(); + +$"{price:N2}" // "1,234.50" +$"{price:C}" // "$1,234.50" +string.Format(germanCulture, "{0}", price) // "1234,5" +price.ToString("N2", CultureInfo.InvariantCulture); +``` + +Note `price.ToString()` with no provider uses the **current culture**, same +as `decimal.ToString()`. Pass an explicit provider anywhere the output is +machine-read (logs, URLs, files) rather than displayed. + +Parsing accepts a span, so a wrapper can be read out of a larger buffer +without slicing it into a string first: + +```csharp +Positive.Parse(line.AsSpan(6, 2), CultureInfo.InvariantCulture); +Positive.TryParse(span, CultureInfo.InvariantCulture, out var count); +``` + +Both interfaces also let a wrapper satisfy generic constraints, which no +amount of reaching for `.Value` at the call site can do for the caller: + +```csharp +static T Read(ReadOnlySpan s) where T : ISpanParsable => T.Parse(s, null); +static string Render(T v, string f) where T : IFormattable => v.ToString(f, null); + +Read>("42"); +Render(price, "C"); +``` ## Arithmetic diff --git a/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs b/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs index a2a58bef..9477cb74 100644 --- a/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs +++ b/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs @@ -8,12 +8,7 @@ namespace StrongTypes.Tests; -/// -/// Binding an options class is what the TypeConverter exists for: without one, -/// ConfigurationBinder finds no way to turn "5" into a strong type, treats -/// the wrapper as a nested object graph, and silently leaves the property at its default — -/// no exception, just a wrong value. These tests pin the whole path end to end. -/// +/// Without a TypeConverter, ConfigurationBinder cannot turn "5" into a strong type and silently leaves the property at its default rather than failing — so these assert the bound value, not merely that binding returned. public class OptionsBindingTests { private sealed class RetryOptions @@ -65,12 +60,7 @@ public void EveryStrongTypeBindsFromConfiguration() Assert.Equal(99, options.OptionalLimit?.Value); } - /// - /// The regression that motivated the converter. default(Positive<int>) is a - /// plausible-looking 1 — the offset encoding that keeps the invariant true for - /// default is exactly what makes a dropped config value impossible to spot — so - /// assert the bound value differs from the default rather than merely that it is set. - /// + /// default(Positive<int>) is a plausible-looking 1, so a dropped config value reads as a real one — hence asserting the bound value differs from the default. [Property] public void BoundValueComesFromConfiguration_NotTheDefault(int configured) { @@ -104,8 +94,6 @@ public void ValueBreakingTheInvariant_ThrowsAndNamesTheReason(string key, string { var exception = Assert.Throws(() => BindViaOptions(Config((key, value)))); - // The binder's own message carries the config path and the offending value; the - // invariant's reason survives as the inner exception the wrapper threw. Assert.Contains(key, exception.Message, StringComparison.Ordinal); Assert.Contains(value, exception.Message, StringComparison.Ordinal); diff --git a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs index 5d39defc..d59f2d9c 100644 --- a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs +++ b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs @@ -5,12 +5,7 @@ namespace StrongTypes.Tests; -/// -/// Composite formatting silently ignores a format specifier on a type that is not -/// $"{price:C}" compiles, never throws, and prints the -/// unformatted number. These tests pin the specifier and the culture actually reaching the -/// underlying value, for every numeric wrapper the generator emits. -/// +/// Composite formatting silently ignores a specifier on a type that is not $"{price:C}" compiles, never throws, and prints the unformatted number — so these pin the specifier and the culture reaching the underlying value. public class NumericFormattingTests { private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); @@ -124,9 +119,8 @@ public void TryParse_Span_BreachedInvariant_ReturnsFalse() // ── Generic constraints ───────────────────────────────────────────── // - // The reason the span interfaces earn their place: without them a wrapper - // cannot be passed to generic code constrained on them, and no amount of - // reaching for .Value at the call site fixes that for the caller. + // What the span interfaces buy that reaching for .Value cannot: a caller's + // generic code constrained on them can accept a wrapper at all. private static string Render(T value, string format) where T : IFormattable => value.ToString(format, American); diff --git a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs index 7080d29f..cf1526b0 100644 --- a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs +++ b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs @@ -8,7 +8,7 @@ namespace StrongTypes; /// Converts between and via , honouring the culture it is handed. /// A strong type that implements . -/// Invalid input surfaces as whatever T.Parse throws — for a Kalicz.StrongTypes wrapper that is the naming the broken invariant, which callers such as ConfigurationBinder surface as the inner exception. Apply to an open generic via instead. +/// Invalid input surfaces as whatever T.Parse throws — for a Kalicz.StrongTypes wrapper, the naming the broken invariant. A generic type cannot name a closed converter in an attribute argument; it uses instead. public sealed class ParsableTypeConverter : TypeConverter where T : IParsable { @@ -28,8 +28,7 @@ public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destina if (destinationType != typeof(string) || value is not T parsed) return base.ConvertTo(context, culture, value, destinationType); - // Must round-trip ConvertFrom, which parses in `culture` — formatting in - // any other culture would re-parse to a different number. + // Must format in the culture ConvertFrom parses in, or the pair does not round-trip. return parsed is IFormattable formattable ? formattable.ToString(null, culture) : parsed.ToString(); } } From 729b1c64481e2e650301f1716aba2d7690ac9538 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Tue, 14 Jul 2026 23:57:51 +0200 Subject: [PATCH 03/24] Remove the StrongTypes.Wpf package (breaking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package existed to compensate for the core types not carrying [TypeConverter]. Its readme states the rationale: "the core package deliberately carries no UI dependency, so the wiring lives here." That premise is false — TypeConverter lives in System.ComponentModel, ships in the base framework, and its largest consumer is ConfigurationBinder. Core still has zero PackageReferences after taking it on. So the hole was never WPF-specific, and patching it per-host meant only WPF got patched: the same gap silently bound Positive options to 1. One misconception, two symptoms. The whole public surface was ParsableTypeConverter and an Application.UseStrongTypes() extension; the provider and descriptor were internal. All of it is superseded by the attribute, which is also strictly better: no global TypeDescriptor mutation, impossible to forget, and it cannot go stale as new strong types are added (the provider's type list was hardcoded, so BoundedInt would have silently missed it). It was also wrong. Its ConvertTo dropped the culture it was handed and formatted with the ambient one, so a de-DE binding rendered 1234.5 as "1234.5" and read it back as 12345. CultureBindingTests pins that. The binding tests stay, retargeted onto core with no registration call — they are now the only proof the attribute satisfies WPF's binding engine. Widened to 25: Digit, nullable struct and nullable reference wrappers, and culture round-trips. NullableStructBindingTests documents a pre-existing surprise found while writing it: clearing a TextBox bound to Positive? raises a validation error rather than nulling, because WPF unwraps Nullable and asks for the underlying converter, never consulting NullableConverter. Unchanged by this commit — the removed package didn't register against Nullable either. Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 1 - Skill/SKILL.md | 5 +- Skill/references/configuration.md | 6 +- Skill/references/wpf.md | 112 +++++++------- StrongTypes.slnx | 1 - docs/diagrams/package-layout-dark.svg | 8 +- docs/diagrams/package-layout.svg | 8 +- readme.md | 47 +++++- src/StrongTypes.Wpf.TestApp/App.xaml.cs | 9 +- .../PersonViewModel.cs | 31 ++++ .../StrongTypes.Wpf.TestApp.csproj | 1 - src/StrongTypes.Wpf.Tests/BindingTests.cs | 99 ++++++------ src/StrongTypes.Wpf.Tests/Bindings.cs | 25 +++ .../CultureBindingTests.cs | 65 ++++++++ .../NullableBindingTests.cs | 146 ++++++++++++++++++ .../StrongTypes.Wpf.Tests.csproj | 1 - src/StrongTypes.Wpf.Tests/TestSetup.cs | 5 +- src/StrongTypes.Wpf/ParsableTypeConverter.cs | 24 --- src/StrongTypes.Wpf/Startup.cs | 30 ---- src/StrongTypes.Wpf/StrongTypes.Wpf.csproj | 33 ---- .../StrongTypesTypeDescriptionProvider.cs | 41 ----- src/StrongTypes.Wpf/readme.md | 35 ----- 22 files changed, 439 insertions(+), 294 deletions(-) create mode 100644 src/StrongTypes.Wpf.Tests/Bindings.cs create mode 100644 src/StrongTypes.Wpf.Tests/CultureBindingTests.cs create mode 100644 src/StrongTypes.Wpf.Tests/NullableBindingTests.cs delete mode 100644 src/StrongTypes.Wpf/ParsableTypeConverter.cs delete mode 100644 src/StrongTypes.Wpf/Startup.cs delete mode 100644 src/StrongTypes.Wpf/StrongTypes.Wpf.csproj delete mode 100644 src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs delete mode 100644 src/StrongTypes.Wpf/readme.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9092d604..d0e9aa52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,6 @@ as part of the same PR. [`StrongTypes.FsCheck`](src/StrongTypes.FsCheck/readme.md), [`StrongTypes.OpenApi.Microsoft`](src/StrongTypes.OpenApi.Microsoft/readme.md), [`StrongTypes.OpenApi.Swashbuckle`](src/StrongTypes.OpenApi.Swashbuckle/readme.md), - [`StrongTypes.Wpf`](src/StrongTypes.Wpf/readme.md), [`StrongTypes.AspNetCore`](src/StrongTypes.AspNetCore/readme.md)) should reflect what the package actually does after your change. Update the one that matches. diff --git a/Skill/SKILL.md b/Skill/SKILL.md index 811ba384..052af5f3 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -24,7 +24,6 @@ demand when about to write code against that surface. | `Kalicz.StrongTypes.FsCheck` | FsCheck `Arbitrary` generators registered via `[Properties(Arbitrary = new[] { typeof(Generators) })]`. | | `Kalicz.StrongTypes.OpenApi.Microsoft` | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so wrappers render as the wire JSON shape, not the CLR shape. | | `Kalicz.StrongTypes.OpenApi.Swashbuckle` | The same idea for Swashbuckle's `AddSwaggerGen()` pipeline — schema filters that produce the wire JSON shape. | -| `Kalicz.StrongTypes.Wpf` | `TypeConverter` infrastructure for two-way MVVM binding to strong-typed view-model properties. One `this.UseStrongTypes()` call in `App.OnStartup`. | | `Kalicz.StrongTypes.AspNetCore` | MVC model binder for `NonEmptyEnumerable` from `[FromForm]` / `[FromQuery]` / `[FromHeader]` / `[FromRoute]`, **plus** opt-out normalization of JSON request-body validation error keys (`$.value` → `Value`) so they match data-annotation / model-binding keys. The binder is niche; `[FromBody]` round-trips wrappers via the core JSON converters without it. | Add packages only when the host project actually hits that stack: @@ -32,7 +31,7 @@ Add packages only when the host project actually hits that stack: - **EfCore** — only if EF Core is in use. - **FsCheck** — only for property-based test projects. - **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines. -- **Wpf** — only for WPF apps that two-way bind to strong-typed VM properties. See `references/wpf.md`. +- **WPF / WinForms** — no package. Two-way binding works off the core package's `[TypeConverter]`s; there is nothing to install or call. A `Kalicz.StrongTypes.Wpf` package existed before v2 — if you find it referenced, remove it. See `references/wpf.md`. - **AspNetCore** — add it when a controller takes `NonEmptyEnumerable` from a non-body source (forms, repeated query params, header lists), **or** when you want JSON request-body validation errors keyed by the property name (`Value`) instead of the System.Text.Json path (`$.value`). The error-key normalization is on by default once `AddStrongTypes()` is called — opt out with `AddStrongTypes(o => o.NormalizeJsonErrorKeys = false)`, or set `o.JsonErrorKeyCasing`. The binder alone is niche — `[FromBody]` already round-trips `NonEmptyEnumerable` via the core JSON converters — but the error-key normalization applies to any JSON API. See `references/aspnetcore.md`. ## Type catalog — what's in the box @@ -69,7 +68,7 @@ demand when about to write code against that surface. | EF Core: `UseStrongTypes` value converters, interval column mapping, `.Unwrap()` LINQ marker | `references/efcore.md` | | FsCheck: shared `Generators` class, shipped arbitraries | `references/fscheck.md` | | OpenAPI: `AddStrongTypes()` for either `AddOpenApi()` (`Kalicz.StrongTypes.OpenApi.Microsoft`) or `AddSwaggerGen()` (`Kalicz.StrongTypes.OpenApi.Swashbuckle`) | `references/openapi.md` | -| WPF: `this.UseStrongTypes()` in `App.OnStartup` for two-way MVVM binding | `references/wpf.md` | +| WPF / WinForms two-way MVVM binding — zero setup, `ValidatesOnExceptions=True`, culture, nullable properties | `references/wpf.md` | | ASP.NET Core MVC: `services.AddStrongTypes()` for `NonEmptyEnumerable` from `[FromForm]` & friends, plus JSON request-body validation error-key normalization | `references/aspnetcore.md` | ## Design philosophy — picking the right wrapper diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 863a7ca0..c375d059 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -77,6 +77,6 @@ earlier one. - **Nullable wrappers still enforce the invariant** when a value is present: `OptionalLimit` may be absent, but `-1` is rejected. - The same `TypeConverter` powers anything that goes through `TypeDescriptor` — - WinForms designers, `PropertyGrid`, and libraries doing generic - string↔object conversion. WPF users still call `this.UseStrongTypes()` - (see `references/wpf.md`). + WPF/WinForms two-way binding (`references/wpf.md`), designers, + `PropertyGrid`, and libraries doing generic string↔object conversion. It is + one mechanism on the type, so none of them need a registration call. diff --git a/Skill/references/wpf.md b/Skill/references/wpf.md index 109607e9..b43a366d 100644 --- a/Skill/references/wpf.md +++ b/Skill/references/wpf.md @@ -1,50 +1,34 @@ -# WPF MVVM binding — `Kalicz.StrongTypes.Wpf` +# WPF MVVM binding -Adds the `TypeConverter` infrastructure WPF needs to two-way bind a -`TextBox` (or any other input control) to a strong-typed view-model -property. +**No package, no setup.** Two-way binding a `TextBox` to a strong-typed +view-model property works off the core `Kalicz.StrongTypes` package alone. -## Why a separate package +> There was a `Kalicz.StrongTypes.Wpf` package requiring a +> `this.UseStrongTypes()` call in `App.OnStartup`. It is **gone as of v2** — +> the core types now carry `[TypeConverter]` themselves. Delete the package +> reference and the call; nothing replaces them. If you see +> `UseStrongTypes()` on an `Application` in old code or a blog post, that is +> the removed API. (The identically-named EF Core and OpenAPI +> `UseStrongTypes()` calls are unrelated and still current.) + +## Why it works WPF's binding pipeline routes `string → T` through -`TypeDescriptor.GetConverter(T)` and never consults `IParsable` -directly. Without a converter, typing into a `TextBox` bound to e.g. -a `NonEmptyString` view-model property silently fails to update the -source. The core `Kalicz.StrongTypes` package deliberately carries no -UI dependency, so the wiring lives here. - -## Wiring - -Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers -every strong type with a string round-trip — `NonEmptyString`, `Email`, -`Digit`, and every closed instantiation of the generic numeric wrappers -(`Positive`, `Negative`, …) — the package installs a -`TypeDescriptionProvider` that synthesises an `IParsable`-backed -converter on demand the first time WPF asks for it. - -Composite types have no single-`TextBox` string form and so get no -converter: bind their parts instead. An interval -(`FiniteInterval`, `Interval`, `IntervalFrom`, -`IntervalUntil`) binds field-by-field via `.Start` / `.End`; -`Maybe` and `NonEmptyEnumerable` likewise bind through their +`TypeDescriptor.GetConverter(T)` and never consults `IParsable` directly. +Every strong type with a string round-trip carries a `[TypeConverter]` that +bridges the two — `NonEmptyString`, `Email`, `Digit`, and every closed +instantiation of the generic numeric wrappers (`Positive`, +`Negative`, …). Nothing to register: the attribute travels with the +type. + +Composite types have no single-`TextBox` string form and so get no converter: +bind their parts instead. An interval (`FiniteInterval`, `Interval`, +`IntervalFrom`, `IntervalUntil`) binds field-by-field via `.Start` / +`.End`; `Maybe` and `NonEmptyEnumerable` likewise bind through their members, not as a whole. -```csharp -public partial class App : Application -{ - protected override void OnStartup(StartupEventArgs e) - { - this.UseStrongTypes(); - base.OnStartup(e); - } -} -``` - ## Bindings — what to write in XAML -After registration, plain MVVM bindings to strong-typed properties -just work: - ```xml ` displays as `1234,5` on a +de-DE binding and reads back unchanged. Don't add an `IValueConverter` to +compensate — a pre-v2 `Kalicz.StrongTypes.Wpf` formatted with the ambient +culture while parsing in the binding's, which turned `1234.5` into `12345` on +round-trip; if a workaround exists in your code for that, delete it. + +## Nullable properties + +A nullable wrapper (`Positive?`, `NonEmptyString?`) binds and validates +normally, but **clearing the box does not set the property to null** — it +raises a validation error. WPF unwraps `Nullable` and asks for the +underlying type's converter, so the BCL's `NullableConverter` (which maps empty +to null, and does exactly that for configuration binding) is never consulted, +and `""` reaches `Positive.Parse`. This is long-standing behaviour, not a +v2 change. If "cleared means null" matters, bind through a `string?` view-model +property and convert in the view-model. ## Other XAML/MVVM frameworks -The same `TypeDescriptor`-based mechanism is used by WinForms and -some other frameworks; in practice this package is currently -documented and tested for WPF. If a user is on Avalonia, MAUI, or -WinForms and hits the same "binding silently fails" symptom, point -them at issue +The same `TypeDescriptor` mechanism is used by WinForms and some other +frameworks, and since the converters now live on the types themselves, nothing +WPF-specific is required for them either. In practice this is documented and +tested for WPF. If a user is on Avalonia, MAUI, or WinForms and hits a +"binding silently fails" symptom, point them at issue [#94](https://github.com/KaliCZ/StrongTypes/issues/94). ## Decision rule -> **Use it whenever a WPF view-model property is a strong type that -> the user can type into.** Display-only bindings (one-way out) work -> without it because WPF goes through `ToString()`. The package -> matters for the inbound (string → T) leg. +> **Nothing to add, nothing to call.** Reference `Kalicz.StrongTypes` and bind. +> The only thing you must remember is `ValidatesOnExceptions=True` on inbound +> (string → T) bindings, or invalid input fails silently. diff --git a/StrongTypes.slnx b/StrongTypes.slnx index 8e04e7ce..8b559a24 100644 --- a/StrongTypes.slnx +++ b/StrongTypes.slnx @@ -33,7 +33,6 @@ - diff --git a/docs/diagrams/package-layout-dark.svg b/docs/diagrams/package-layout-dark.svg index ef8ef93c..a107b062 100644 --- a/docs/diagrams/package-layout-dark.svg +++ b/docs/diagrams/package-layout-dark.svg @@ -143,16 +143,16 @@ Predefined value generators - + APP SURFACE - + - Kalicz.StrongTypes.Wpf + WPF / WinForms - Two-way MVVM binding + Two-way MVVM binding — built in diff --git a/docs/diagrams/package-layout.svg b/docs/diagrams/package-layout.svg index 5412897c..82944164 100644 --- a/docs/diagrams/package-layout.svg +++ b/docs/diagrams/package-layout.svg @@ -143,16 +143,16 @@ Predefined value generators - + APP SURFACE - + - Kalicz.StrongTypes.Wpf + WPF / WinForms - Two-way MVVM binding + Two-way MVVM binding — built in diff --git a/readme.md b/readme.md index f3f824c1..3e112bbd 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ [![NuGet version](https://img.shields.io/nuget/v/Kalicz.StrongTypes?label=nuget)](https://www.nuget.org/packages/Kalicz.StrongTypes/) [![Downloads](https://img.shields.io/nuget/dt/Kalicz.StrongTypes?label=downloads)](https://www.nuget.org/packages/Kalicz.StrongTypes/) [![License](https://img.shields.io/github/license/KaliCZ/StrongTypes)](https://github.com/KaliCZ/StrongTypes/blob/main/license.txt) -StrongTypes adds small, focused types that make everyday code safer and more expressive. Every type ships with `System.Text.Json` converters out of the box, so invalid JSON fails at deserialization. The types can be stored directly in EF Core entities via the EfCore package, OpenAPI documentation is supported through the Microsoft or Swashbuckle OpenAPI packages, and WPF is supported via the WPF package — see [Packages](#packages) below. +StrongTypes adds small, focused types that make everyday code safer and more expressive. Every type ships with `System.Text.Json` converters out of the box, so invalid JSON fails at deserialization, and a `TypeConverter`, so the same invariant validates `appsettings.json` as it binds and WPF two-way bindings work with no setup. The types can be stored directly in EF Core entities via the EfCore package, and OpenAPI documentation is supported through the Microsoft or Swashbuckle OpenAPI packages — see [Packages](#packages) below. > 🤖 Letting Claude Code or Codex write code in a project that uses > StrongTypes? See [Use with Claude or Codex](#use-with-claude-or-codex) @@ -28,6 +28,7 @@ StrongTypes adds small, focused types that make everyday code safer and more exp - [Numeric wrappers: `Positive`, `NonNegative`, `Negative`, `NonPositive`](#numeric-wrappers) - [What you get for free](#what-you-get-for-free) - [JSON serialization](#json-serialization) + - [Configuration binding](#configuration-binding) - [EF Core persistence](#ef-core-persistence) - [OpenAPI / Swagger schema](#openapi--swagger-schema) - [WPF MVVM binding](#wpf-mvvm-binding) @@ -140,6 +141,34 @@ All strong types ship with `System.Text.Json` converters attached via `[JsonConv [↑ Back to contents](#contents) +### Configuration binding + +All strong types ship a `TypeConverter` attached via `[TypeConverter]`, which is what `ConfigurationBinder` uses — so a wrapper works directly on an options class, and its invariant becomes the config validation rule: + +```csharp +public sealed class RetryOptions +{ + public Positive MaxRetries { get; set; } + public NonEmptyString? Name { get; set; } +} + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Retry")) + .ValidateOnStart(); +``` + +A bad value fails with the path, the value, and the reason — no `[Range]` and no `IValidateOptions` needed: + +``` +InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries' + to type 'StrongTypes.Positive`1[System.Int32]'. + ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') +``` + +Reach for `ValidateOnStart()`: options binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. + +[↑ Back to contents](#contents) + ### EF Core persistence If you want to store strong types directly on your EF Core entities, add the companion package [`Kalicz.StrongTypes.EfCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore/). It provides the value converters needed to map `NonEmptyString`, `Positive`, and other numeric types to their underlying column types. See the package [readme](https://github.com/KaliCZ/StrongTypes/blob/main/src/StrongTypes.EfCore/readme.md) for setup details. @@ -162,9 +191,20 @@ Pick the one that matches the generator your app already uses. They're not inter ### WPF MVVM binding -For WPF applications, add the package [`Kalicz.StrongTypes.Wpf`](https://www.nuget.org/packages/Kalicz.StrongTypes.Wpf/) to enable bindings including two-way. One `this.UseStrongTypes()` call in `App.OnStartup` to register. +Nothing to install, nothing to call. WPF resolves `string → T` through `TypeDescriptor`, and every strong type carries a `[TypeConverter]`, so two-way bindings just work: + +```xml + +``` + +…where `Name` is a view-model property of type `NonEmptyString`. `ValidatesOnExceptions=True` is the load-bearing piece: it turns the `ArgumentException` a strong type throws on invalid input into a `ValidationError`, driving WPF's standard red-border template. Without it the binding swallows the failure silently. + +> **Changed in v2.** This used to require the `Kalicz.StrongTypes.Wpf` package and a `this.UseStrongTypes()` call in `App.OnStartup`. Both are gone — the converters now live on the types. Drop the package reference and the call. -Other UI frameworks (WinForms, MAUI, Avalonia, …) aren't covered yet — see [issue #94](https://github.com/KaliCZ/StrongTypes/issues/94). +The same `TypeDescriptor` mechanism backs WinForms and designers. MAUI and Avalonia aren't covered yet — see [issue #94](https://github.com/KaliCZ/StrongTypes/issues/94). [↑ Back to contents](#contents) @@ -752,7 +792,6 @@ Result[], string> ParseOrderQuantities(IEnumerable inputs) | [`Kalicz.StrongTypes.OpenApi.Microsoft`](https://www.nuget.org/packages/Kalicz.StrongTypes.OpenApi.Microsoft/) | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so the generated document matches the wire JSON. | [readme](src/StrongTypes.OpenApi.Microsoft/readme.md) | | [`Kalicz.StrongTypes.OpenApi.Swashbuckle`](https://www.nuget.org/packages/Kalicz.StrongTypes.OpenApi.Swashbuckle/) | Schema filters for `Swashbuckle.AspNetCore` (`AddSwaggerGen()`) so the generated Swagger document matches the wire JSON. | [readme](src/StrongTypes.OpenApi.Swashbuckle/readme.md) | | [`Kalicz.StrongTypes.AspNetCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.AspNetCore/) | MVC model binder for `NonEmptyEnumerable` from `[FromForm]`, `[FromQuery]`, `[FromHeader]`, and `[FromRoute]`, plus opt-out normalization of JSON request-body validation error keys (`$.value` → `Value`, configurable via `AddStrongTypes(o => o.NormalizeJsonErrorKeys = …)`). The binder isn't needed for JSON APIs — `[FromBody]` round-trips wrappers via the main package's JSON converters — but the error-key normalization applies to any JSON API. | [readme](src/StrongTypes.AspNetCore/readme.md) | -| [`Kalicz.StrongTypes.Wpf`](https://www.nuget.org/packages/Kalicz.StrongTypes.Wpf/) | `TypeConverter`s that bridge `IParsable` into `TypeDescriptor`, enabling two-way MVVM binding to strong types in WPF (and any framework that resolves converters via `TypeDescriptor`). | [readme](src/StrongTypes.Wpf/readme.md) | [↑ Back to contents](#contents) diff --git a/src/StrongTypes.Wpf.TestApp/App.xaml.cs b/src/StrongTypes.Wpf.TestApp/App.xaml.cs index 563fd010..c589f96c 100644 --- a/src/StrongTypes.Wpf.TestApp/App.xaml.cs +++ b/src/StrongTypes.Wpf.TestApp/App.xaml.cs @@ -1,10 +1,3 @@ namespace StrongTypes.Wpf.TestApp; -public partial class App : System.Windows.Application -{ - protected override void OnStartup(System.Windows.StartupEventArgs e) - { - this.UseStrongTypes(); - base.OnStartup(e); - } -} +public partial class App : System.Windows.Application; diff --git a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs index c70c62a0..966af14b 100644 --- a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs +++ b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs @@ -10,6 +10,10 @@ public sealed class PersonViewModel : INotifyPropertyChanged private NonEmptyString _name = NonEmptyString.Create("Alice"); private Email _email = Email.Create("alice@example.com"); private Positive _age = Positive.Create(30); + private Digit _tier = Digit.Create('7'); + private Positive _salary = Positive.Create(1234.5m); + private NonEmptyString? _nickname; + private Positive? _score; public NonEmptyString Name { @@ -29,6 +33,33 @@ public Positive Age set { _age = value; Raise(); } } + public Digit Tier + { + get => _tier; + set { _tier = value; Raise(); } + } + + /// Culture-sensitive: a decimal separator differs between cultures, so this is what pins ConverterCulture round-tripping. + public Positive Salary + { + get => _salary; + set { _salary = value; Raise(); } + } + + /// A nullable reference wrapper — WPF sees the property type as , nullability being compile-time only. + public NonEmptyString? Nickname + { + get => _nickname; + set { _nickname = value; Raise(); } + } + + /// A nullable struct wrapper — WPF routes this through the BCL's NullableConverter. + public Positive? Score + { + get => _score; + set { _score = value; Raise(); } + } + public event PropertyChangedEventHandler? PropertyChanged; private void Raise([CallerMemberName] string? propertyName = null) => diff --git a/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj b/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj index 140e72e9..83121a6f 100644 --- a/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj +++ b/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj @@ -13,6 +13,5 @@ - diff --git a/src/StrongTypes.Wpf.Tests/BindingTests.cs b/src/StrongTypes.Wpf.Tests/BindingTests.cs index 089a7fc0..1afe1bf0 100644 --- a/src/StrongTypes.Wpf.Tests/BindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/BindingTests.cs @@ -4,9 +4,15 @@ using System.Windows.Data; using StrongTypes.Wpf.TestApp; using Xunit; +using static StrongTypes.Wpf.Tests.Bindings; namespace StrongTypes.Wpf.Tests; +/// +/// WPF routes string → T through and never +/// consults IParsable<T>, so these are the only coverage proving the core +/// [TypeConverter] satisfies the binding engine. No registration call is made anywhere. +/// public class NonEmptyStringBindingTests { [Fact] @@ -64,24 +70,9 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = " "; Assert.Equal(NonEmptyString.Create("Alice"), vm.Name); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } - - private static Binding OneWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.OneWay, - }; - - private static Binding TwoWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.TwoWay, - UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, - ValidatesOnExceptions = true, - NotifyOnValidationError = true, - }; } public class EmailBindingTests @@ -126,24 +117,9 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = "not-an-email"; Assert.Equal(Email.Create("alice@example.com"), vm.Email); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } - - private static Binding OneWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.OneWay, - }; - - private static Binding TwoWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.TwoWay, - UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, - ValidatesOnExceptions = true, - NotifyOnValidationError = true, - }; } public class PositiveIntBindingTests @@ -188,7 +164,7 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = "0"; Assert.Equal(Positive.Create(30), vm.Age); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } @@ -204,22 +180,55 @@ public void TwoWay_NonNumericInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = "abc"; Assert.Equal(Positive.Create(30), vm.Age); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } +} - private static Binding OneWay(string path, object source) => new(path) +/// A non-generic struct wrapper — resolves its converter by attribute rather than through StrongTypeConverter's open-generic bootstrap. +public class DigitBindingTests +{ + [Fact] + public void OneWay_DisplaysCurrentValue() { - Source = source, - Mode = BindingMode.OneWay, - }; + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Tier), vm)); - private static Binding TwoWay(string path, object source) => new(path) + Assert.Equal("7", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() { - Source = source, - Mode = BindingMode.TwoWay, - UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, - ValidatesOnExceptions = true, - NotifyOnValidationError = true, - }; + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Tier), vm)); + + textBox.Text = "3"; + + Assert.Equal(Digit.Create('3'), vm.Tier); + }); + } + + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Tier), vm)); + + textBox.Text = "42"; + + Assert.Equal(Digit.Create('7'), vm.Tier); + Assert.True(Validation.GetHasError(textBox)); + }); + } } diff --git a/src/StrongTypes.Wpf.Tests/Bindings.cs b/src/StrongTypes.Wpf.Tests/Bindings.cs new file mode 100644 index 00000000..70e3dcea --- /dev/null +++ b/src/StrongTypes.Wpf.Tests/Bindings.cs @@ -0,0 +1,25 @@ +#nullable enable + +using System.Globalization; +using System.Windows.Data; + +namespace StrongTypes.Wpf.Tests; + +internal static class Bindings +{ + public static Binding OneWay(string path, object source) => new(path) + { + Source = source, + Mode = BindingMode.OneWay, + }; + + public static Binding TwoWay(string path, object source, CultureInfo? culture = null) => new(path) + { + Source = source, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, + ValidatesOnExceptions = true, + NotifyOnValidationError = true, + ConverterCulture = culture, + }; +} diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs new file mode 100644 index 00000000..2e9d6f4a --- /dev/null +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -0,0 +1,65 @@ +#nullable enable + +using System.Globalization; +using System.Windows.Controls; +using System.Windows.Data; +using StrongTypes.Wpf.TestApp; +using Xunit; +using static StrongTypes.Wpf.Tests.Bindings; + +namespace StrongTypes.Wpf.Tests; + +/// +/// A binding hands the converter one culture for both directions, so display and write-back must +/// agree on it. The converter this replaced formatted with the ambient culture while parsing in the +/// binding's: on a de-DE binding it rendered 1234.5 as "1234.5", which reads back as 12345. +/// +public class CultureBindingTests +{ + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + + [Fact] + public void OneWay_FormatsInTheBindingCulture() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, German)); + + Assert.Equal("1234,5", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ParsesInTheBindingCulture() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, German)); + + textBox.Text = "9876,5"; + + Assert.Equal(Positive.Create(9876.5m), vm.Salary); + }); + } + + /// Display then re-commit unchanged text must be a no-op; when the two directions disagree on culture it silently multiplies the value by 10. + [Fact] + public void DisplayedTextRoundTripsBackToTheSameValue() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, German)); + + textBox.Text = textBox.Text; + + Assert.Equal(Positive.Create(1234.5m), vm.Salary); + Assert.False(Validation.GetHasError(textBox)); + }); + } +} diff --git a/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs b/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs new file mode 100644 index 00000000..86791b79 --- /dev/null +++ b/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs @@ -0,0 +1,146 @@ +#nullable enable + +using System.Windows.Controls; +using System.Windows.Data; +using StrongTypes.Wpf.TestApp; +using Xunit; +using static StrongTypes.Wpf.Tests.Bindings; + +namespace StrongTypes.Wpf.Tests; + +/// +/// The two nullable shapes resolve their converter differently — a nullable struct wrapper goes +/// through the BCL's NullableConverter, which we do not own, while a nullable reference +/// wrapper hits our converter directly because nullability is erased at runtime. Both are pinned +/// here because neither is obvious from the property declaration. +/// +public class NullableStructBindingTests +{ + [Fact] + public void OneWay_Null_DisplaysEmpty() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Score), vm)); + + Assert.Equal("", textBox.Text); + }); + } + + [Fact] + public void OneWay_Value_DisplaysIt() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Score), vm)); + + Assert.Equal("10", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Score), vm)); + + textBox.Text = "10"; + + Assert.Equal(Positive.Create(10), vm.Score); + }); + } + + /// + /// Clearing the box does not null the source. WPF unwraps Nullable<T> and asks + /// TypeDescriptor for the underlying type's converter, so the BCL's NullableConverter + /// — which would map empty to null, and does for configuration binding — is never consulted; + /// "" reaches Positive<int>.Parse and fails. Surprising, but long-standing: + /// no converter has ever been registered against Nullable<T> here. + /// + [Fact] + public void TwoWay_ClearedInput_RaisesValidationErrorRatherThanNulling() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Score), vm)); + + textBox.Text = ""; + + Assert.Equal(Positive.Create(10), vm.Score); + Assert.True(Validation.GetHasError(textBox)); + }); + } + + /// Nullable does not mean unvalidated: a present value still has to satisfy the invariant. + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Score), vm)); + + textBox.Text = "-1"; + + Assert.Equal(Positive.Create(10), vm.Score); + Assert.True(Validation.GetHasError(textBox)); + }); + } +} + +public class NullableReferenceBindingTests +{ + [Fact] + public void OneWay_Null_DisplaysEmpty() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Nickname), vm)); + + Assert.Equal("", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Nickname), vm)); + + textBox.Text = "Ally"; + + Assert.Equal(NonEmptyString.Create("Ally"), vm.Nickname); + }); + } + + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = NonEmptyString.Create("Ally") }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Nickname), vm)); + + textBox.Text = " "; + + Assert.Equal(NonEmptyString.Create("Ally"), vm.Nickname); + Assert.True(Validation.GetHasError(textBox)); + }); + } +} diff --git a/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj b/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj index d7df5d88..a07b607c 100644 --- a/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj +++ b/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj @@ -23,7 +23,6 @@ - diff --git a/src/StrongTypes.Wpf.Tests/TestSetup.cs b/src/StrongTypes.Wpf.Tests/TestSetup.cs index 4da69a85..ff9d8bcb 100644 --- a/src/StrongTypes.Wpf.Tests/TestSetup.cs +++ b/src/StrongTypes.Wpf.Tests/TestSetup.cs @@ -7,6 +7,9 @@ namespace StrongTypes.Wpf.Tests; internal static class TestSetup { + // No strong-type registration: binding must work off the [TypeConverter] the + // core package puts on each type. An Application instance is still required + // for WPF's binding engine to resolve resources. [ModuleInitializer] - internal static void Init() => StaThread.Run(() => new Application().UseStrongTypes()); + internal static void Init() => StaThread.Run(() => new Application()); } diff --git a/src/StrongTypes.Wpf/ParsableTypeConverter.cs b/src/StrongTypes.Wpf/ParsableTypeConverter.cs deleted file mode 100644 index 1f7ceda8..00000000 --- a/src/StrongTypes.Wpf/ParsableTypeConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; - -namespace StrongTypes.Wpf; - -/// A that converts between and via . Throws the same exception that T.Parse throws when the input cannot be parsed; pair the binding with ValidatesOnExceptions=True to surface that as a WPF ValidationError. -/// A strong type that implements . -public sealed class ParsableTypeConverter : TypeConverter where T : IParsable -{ - public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => - sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); - - public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => - destinationType == typeof(string) || base.CanConvertTo(context, destinationType); - - public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => - value is string s ? T.Parse(s, culture) : base.ConvertFrom(context, culture, value); - - public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => - destinationType == typeof(string) && value is T t - ? t.ToString() - : base.ConvertTo(context, culture, value, destinationType); -} diff --git a/src/StrongTypes.Wpf/Startup.cs b/src/StrongTypes.Wpf/Startup.cs deleted file mode 100644 index a5e2e597..00000000 --- a/src/StrongTypes.Wpf/Startup.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel; -using System.Windows; - -namespace StrongTypes.Wpf; - -public static class ApplicationStartupExtensions -{ - private static readonly object _gate = new(); - private static bool _registered; - - /// Wires into for every strong type shipped in Kalicz.StrongTypes, enabling two-way MVVM binding from TextBox.Text to strong-typed view-model properties. Call once from App.OnStartup. Idempotent. - /// , for fluent chaining. - public static Application UseStrongTypes(this Application application) - { - if (_registered) - return application; - - lock (_gate) - { - if (_registered) - return application; - - var parent = TypeDescriptor.GetProvider(typeof(object)); - TypeDescriptor.AddProvider(new StrongTypesTypeDescriptionProvider(parent), typeof(object)); - _registered = true; - } - - return application; - } -} diff --git a/src/StrongTypes.Wpf/StrongTypes.Wpf.csproj b/src/StrongTypes.Wpf/StrongTypes.Wpf.csproj deleted file mode 100644 index d5846b15..00000000 --- a/src/StrongTypes.Wpf/StrongTypes.Wpf.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - net10.0-windows - 14.0 - enable - enable - true - true - true - CS1591 - - 0.0.0-dev - Kalicz.StrongTypes.Wpf - KaliCZ - Copyright © 2026 KaliCZ - WPF MVVM binding support for Kalicz.StrongTypes. Adds an Application.UseStrongTypes() extension that wires TypeConverters into TypeDescriptor for NonEmptyString, Email, Digit, and every closed instantiation of Positive<T>, NonNegative<T>, Negative<T>, NonPositive<T> — enabling two-way TextBox bindings to strong-typed view-model properties. - StrongTypes, WPF, MVVM, Binding, TypeConverter, NonEmptyString, Email - MIT - false - https://github.com/KaliCZ/StrongTypes - git - https://github.com/KaliCZ/StrongTypes.git - readme.md - true - true - - - - - - - - diff --git a/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs b/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs deleted file mode 100644 index 5a77ef97..00000000 --- a/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.ComponentModel; - -namespace StrongTypes.Wpf; - -internal sealed class StrongTypesTypeDescriptionProvider(TypeDescriptionProvider parent) : TypeDescriptionProvider(parent) -{ - public override ICustomTypeDescriptor? GetTypeDescriptor(Type objectType, object? instance) - { - var inner = base.GetTypeDescriptor(objectType, instance); - if (inner is null) - return null; - var converter = TryCreateConverter(objectType); - return converter is null ? inner : new StrongTypeDescriptor(inner, converter); - } - - private static TypeConverter? TryCreateConverter(Type type) - { - if (type == typeof(NonEmptyString)) - return new ParsableTypeConverter(); - if (type == typeof(Email)) - return new ParsableTypeConverter(); - if (type == typeof(Digit)) - return new ParsableTypeConverter(); - if (!type.IsGenericType) - return null; - var definition = type.GetGenericTypeDefinition(); - if (definition != typeof(Positive<>) - && definition != typeof(NonNegative<>) - && definition != typeof(Negative<>) - && definition != typeof(NonPositive<>)) - return null; - var converterType = typeof(ParsableTypeConverter<>).MakeGenericType(type); - return (TypeConverter)Activator.CreateInstance(converterType)!; - } -} - -internal sealed class StrongTypeDescriptor(ICustomTypeDescriptor parent, TypeConverter converter) : CustomTypeDescriptor(parent) -{ - public override TypeConverter GetConverter() => converter; -} diff --git a/src/StrongTypes.Wpf/readme.md b/src/StrongTypes.Wpf/readme.md deleted file mode 100644 index db2c6f42..00000000 --- a/src/StrongTypes.Wpf/readme.md +++ /dev/null @@ -1,35 +0,0 @@ -# Kalicz.StrongTypes.Wpf - -[![NuGet version](https://img.shields.io/nuget/v/Kalicz.StrongTypes.Wpf?label=nuget)](https://www.nuget.org/packages/Kalicz.StrongTypes.Wpf/) [![Downloads](https://img.shields.io/nuget/dt/Kalicz.StrongTypes.Wpf?label=downloads)](https://www.nuget.org/packages/Kalicz.StrongTypes.Wpf/) [![License](https://img.shields.io/github/license/KaliCZ/StrongTypes)](https://github.com/KaliCZ/StrongTypes/blob/main/license.txt) - -WPF MVVM binding support for [Kalicz.StrongTypes](https://www.nuget.org/packages/Kalicz.StrongTypes). With this package referenced and `UseStrongTypes()` called once in `App.OnStartup`, two-way binding from a `TextBox.Text` to a strong-typed view-model property just works. - -## Why a separate package - -WPF's binding pipeline routes `string → T` through `TypeDescriptor.GetConverter(T)` and never consults `IParsable` directly. Without a converter, typing into a `TextBox` bound to a strong-typed property silently fails to update the source. The core `Kalicz.StrongTypes` package deliberately avoids any UI dependency, so the wiring lives here. - -## Usage - -Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers every strong type that has a string round-trip — `NonEmptyString`, `Email`, `Digit`, and every closed instantiation of the generic numeric wrappers (`Positive`, `Negative`, …) — because the package installs a `TypeDescriptionProvider` that synthesizes an `IParsable`-backed converter on demand the first time WPF asks for it. Composite types with no single-`TextBox` form (the interval types, `Maybe`, `NonEmptyEnumerable`) get no converter — bind their parts (`.Start` / `.End`, etc.) instead. - -```csharp -public partial class App : Application -{ - protected override void OnStartup(StartupEventArgs e) - { - this.UseStrongTypes(); - base.OnStartup(e); - } -} -``` - -After registration, a plain MVVM binding works: - -```xml - -``` - -…where `Name` is a view-model property of type `NonEmptyString`. `ValidatesOnExceptions=True` turns the strong type's `ArgumentException` (thrown by `Create` / `Parse` when validation fails) into a `ValidationError` on the binding, which in turn drives the standard WPF "invalid input" red border. From 88a4c6d839dcf68b820aa307b4f594e5c6413d50 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 07:48:57 +0200 Subject: [PATCH 04/24] Pin the host culture in the WPF culture tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These asserted a de-DE binding renders 1234.5 as "1234,5", but inherited the machine's culture. On a decimal-comma host — cs-CZ, de-DE — a converter that formatted with the ambient culture rather than the binding's passes anyway, so the tests only caught the bug on an en-US agent. Verified: run against the removed package they go green here and corrupt 1234.5 to 12345 on en-US. Pinning en-US makes the separators disagree, so the assertions can only hold if the binding's culture is the one used. Co-Authored-By: Claude Opus 4.8 --- .../CultureBindingTests.cs | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs index 2e9d6f4a..36d39d46 100644 --- a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -1,5 +1,6 @@ #nullable enable +using System; using System.Globalization; using System.Windows.Controls; using System.Windows.Data; @@ -11,17 +12,28 @@ namespace StrongTypes.Wpf.Tests; /// /// A binding hands the converter one culture for both directions, so display and write-back must -/// agree on it. The converter this replaced formatted with the ambient culture while parsing in the -/// binding's: on a de-DE binding it rendered 1234.5 as "1234.5", which reads back as 12345. +/// agree on it — and on the binding's culture, not the host's. /// public class CultureBindingTests { private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); - [Fact] - public void OneWay_FormatsInTheBindingCulture() + /// + /// Pinned, and deliberately not a decimal-comma culture: a converter that formatted with the + /// ambient culture instead of the binding's would still pass every assertion below on a + /// de-DE — or cs-CZ — host, because the two separators happen to agree. + /// + private static readonly CultureInfo Host = CultureInfo.GetCultureInfo("en-US"); + + private static void RunOnAmericanHost(Action body) => StaThread.Run(() => { - StaThread.Run(() => + CultureInfo.CurrentCulture = Host; + body(); + }); + + [Fact] + public void OneWay_FormatsInTheBindingCultureNotTheHostCulture() => + RunOnAmericanHost(() => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; var textBox = new TextBox(); @@ -29,12 +41,10 @@ public void OneWay_FormatsInTheBindingCulture() Assert.Equal("1234,5", textBox.Text); }); - } [Fact] - public void TwoWay_ParsesInTheBindingCulture() - { - StaThread.Run(() => + public void TwoWay_ParsesInTheBindingCulture() => + RunOnAmericanHost(() => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; var textBox = new TextBox(); @@ -44,13 +54,15 @@ public void TwoWay_ParsesInTheBindingCulture() Assert.Equal(Positive.Create(9876.5m), vm.Salary); }); - } - /// Display then re-commit unchanged text must be a no-op; when the two directions disagree on culture it silently multiplies the value by 10. + /// + /// Committing the displayed text unedited must be a no-op. When the directions disagree on + /// culture it is not: the box shows "1234.5", de-DE reads the dot as a group separator, and the + /// view model silently becomes 12345. + /// [Fact] - public void DisplayedTextRoundTripsBackToTheSameValue() - { - StaThread.Run(() => + public void DisplayedTextRoundTripsBackToTheSameValue() => + RunOnAmericanHost(() => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; var textBox = new TextBox(); @@ -61,5 +73,16 @@ public void DisplayedTextRoundTripsBackToTheSameValue() Assert.Equal(Positive.Create(1234.5m), vm.Salary); Assert.False(Validation.GetHasError(textBox)); }); - } + + /// The host's own culture still drives a binding that does not name one. + [Fact] + public void NoConverterCulture_FormatsInTheHostCulture() => + RunOnAmericanHost(() => + { + var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm)); + + Assert.Equal("1234.5", textBox.Text); + }); } From b72359247f3531a6337566d84d263f7e7f9fbb45 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 07:57:10 +0200 Subject: [PATCH 05/24] Cover both binding paths, and pin the null/empty matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite only exercised IOptions and assumed ConfigurationBinder.Get matched. Every scenario now runs through both as a theory parameter, so the equivalence is asserted rather than assumed. They do agree on every row. Also pins what null and "" actually do, which is neither uniform nor guessable: - "" throws for every wrapper except a nullable struct one, where the BCL's NullableConverter maps it to null before our converter is consulted. A nullable reference wrapper gets no such treatment — NonEmptyString? is the same runtime type as NonEmptyString — so "" reaches Parse and fails. Note WPF differs here: it unwraps Nullable itself, so the same "" is a validation error there rather than null. - An explicit null nulls even a non-nullable NonEmptyString: nullability is erased before the binder runs, so it is no different from a string. The invariant constrains every value the type can hold, not whether one is assigned at all. - An explicit null overwrites a property initialised in the options class; an absent key leaves it. Co-Authored-By: Claude Opus 4.8 --- Skill/references/configuration.md | 30 +++ readme.md | 2 + .../ConfigurationBindingTests.cs | 224 ++++++++++++++++++ .../ComponentModel/OptionsBindingTests.cs | 133 ----------- .../StrongTypes.Tests.csproj | 1 + 5 files changed, 257 insertions(+), 133 deletions(-) create mode 100644 src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs delete mode 100644 src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index c375d059..a3caf9e7 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -65,6 +65,36 @@ even with **no** `IValidateOptions` registered. Always add it when options hold strong types — otherwise the invariant buys you a later crash, not an earlier one. +## `null` and `""` — the exact matrix + +Not uniform, and not guessable. Measured; `ConfigurationBinder.Get` and +`IOptions.Value` agree on **every** row: + +| in `appsettings.json` | `NonEmptyString` | `NonEmptyString?` | `Positive` | `Positive?` | +| --------------------- | ---------------- | ----------------- | --------------- | ---------------- | +| key absent | default kept | `null` | `1` (`default`) | `null` | +| `null` | **`null`** | `null` | `1` (`default`) | `null` | +| `""` | **throws** | **throws** | **throws** | **`null`** | +| `" "` | **throws** | **throws** | throws (format) | throws (format) | +| valid | binds | binds | binds | binds | +| invariant breach | throws | throws | throws | throws | + +Three things in there surprise people: + +- **`""` is not uniform.** It throws for everything *except* a nullable **struct** + wrapper, where it binds to `null` — a nullable struct resolves to the BCL's + `NullableConverter`, which maps empty to null before our converter is consulted. + A nullable *reference* wrapper gets no such treatment (`NonEmptyString?` is the + same runtime type as `NonEmptyString`), so `""` reaches `Parse` and fails. + Don't reach for `""` to mean "unset" — omit the key. +- **An explicit `null` nulls even a non-nullable reference property.** Nullability + is erased by the time the binder runs, so `"Name": null` leaves a + `NonEmptyString Name` holding `null`, exactly as it would a `string`. The + invariant constrains every value the type can hold; it cannot stop the binder + assigning none. Omit the key rather than writing `null`. +- **An explicit `null` overwrites, an absent key does not.** `"Name": null` clears + a property initialised in the options class; leaving the key out keeps it. + ## Things worth knowing - **A missing key leaves the default**, it does not throw. `default(Positive)` diff --git a/readme.md b/readme.md index 3e112bbd..3a22937e 100644 --- a/readme.md +++ b/readme.md @@ -167,6 +167,8 @@ InvalidOperationException: Failed to convert configuration value '-5' at 'Retry: Reach for `ValidateOnStart()`: options binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. +Two edges worth knowing, both inherited from `ConfigurationBinder` rather than introduced here. An explicit `"Name": null` nulls even a non-nullable `NonEmptyString` — nullability is erased by the time the binder runs, so omit the key instead of writing `null`. And `""` throws for every wrapper *except* a nullable struct one (`Positive?`), where the BCL's `NullableConverter` maps it to `null` first. The full matrix is in the [skill reference](Skill/references/configuration.md). + [↑ Back to contents](#contents) ### EF Core persistence diff --git a/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs b/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs new file mode 100644 index 00000000..724498ac --- /dev/null +++ b/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs @@ -0,0 +1,224 @@ +using System; +using System.IO; +using System.Text; +using FsCheck.Xunit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace StrongTypes.Tests; + +/// +/// Without a TypeConverter, ConfigurationBinder cannot turn "5" into a strong +/// type and silently leaves the property at its default rather than failing — so these assert the +/// bound value, not merely that binding returned. +/// +/// +/// Every scenario runs through both 's Get<T> and the +/// pipeline. They share a conversion path and so far agree on +/// every row, but that is asserted here rather than assumed. +/// +public class ConfigurationBindingTests +{ + public enum BindingPath + { + ConfigurationGet, + Options, + } + + public static TheoryData Paths => new() { BindingPath.ConfigurationGet, BindingPath.Options }; + + private sealed class RetryOptions + { + public NonEmptyString Name { get; set; } = NonEmptyString.Create("initial"); + public NonEmptyString? Nickname { get; set; } + public Positive MaxRetries { get; set; } + public Positive? Score { get; set; } + public NonNegative InitialDelaySeconds { get; set; } + public Email? Contact { get; set; } + public Digit Tier { get; set; } + } + + /// JSON property assignments placed inside the Retry object. + private static IConfiguration Config(string settings) => + new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes("{\"Retry\":{" + settings + "}}"))) + .Build(); + + private static RetryOptions Bind(BindingPath path, string settings) + { + var section = Config(settings).GetSection("Retry"); + if (path is BindingPath.ConfigurationGet) + return section.Get() ?? new RetryOptions(); + + var services = new ServiceCollection(); + services.AddOptions().Bind(section); + return services.BuildServiceProvider().GetRequiredService>().Value; + } + + // ── The scenario the converter unlocks ────────────────────────────── + + [Theory] + [MemberData(nameof(Paths))] + public void EveryStrongTypeBindsFromConfiguration(BindingPath path) + { + var options = Bind(path, """ + "Name": "checkout", + "Nickname": "co", + "MaxRetries": 5, + "Score": 99, + "InitialDelaySeconds": 0, + "Contact": "ops@example.com", + "Tier": 7 + """); + + Assert.Equal("checkout", options.Name.Value); + Assert.Equal("co", options.Nickname?.Value); + Assert.Equal(5, options.MaxRetries.Value); + Assert.Equal(99, options.Score?.Value); + Assert.Equal(0, options.InitialDelaySeconds.Value); + Assert.Equal("ops@example.com", options.Contact?.Address); + Assert.Equal(7, options.Tier.Value); + } + + /// default(Positive<int>) is a plausible-looking 1, so a dropped config value reads as a real one — hence asserting the bound value differs from the default. + [Property] + public void BoundValueComesFromConfiguration_NotTheDefault(int configured) + { + if (configured <= 0 || configured == default(Positive).Value) return; + + foreach (var path in new[] { BindingPath.ConfigurationGet, BindingPath.Options }) + { + var options = Bind(path, $"\"MaxRetries\": {configured}"); + + Assert.Equal(configured, options.MaxRetries.Value); + Assert.NotEqual(default(Positive).Value, options.MaxRetries.Value); + } + } + + // ── Absent vs. explicit null ──────────────────────────────────────── + + [Theory] + [MemberData(nameof(Paths))] + public void AbsentKey_LeavesTheDefault(BindingPath path) + { + var options = Bind(path, "\"Tier\": 7"); + + Assert.Equal("initial", options.Name.Value); + Assert.Equal(default(Positive).Value, options.MaxRetries.Value); + Assert.Null(options.Nickname); + Assert.Null(options.Score); + } + + /// An explicit null is not the same as an absent key: it overwrites, so a struct wrapper falls back to default rather than keeping the value the object was constructed with. + [Theory] + [MemberData(nameof(Paths))] + public void ExplicitNull_OnAStructWrapper_LeavesTheDefault(BindingPath path) + { + var options = Bind(path, "\"MaxRetries\": null, \"Score\": null"); + + Assert.Equal(default(Positive).Value, options.MaxRetries.Value); + Assert.Null(options.Score); + } + + [Theory] + [MemberData(nameof(Paths))] + public void ExplicitNull_OnANullableReferenceWrapper_IsNull(BindingPath path) => + Assert.Null(Bind(path, "\"Nickname\": null").Nickname); + + /// + /// A null in config nulls even a non-nullable reference property, because nullability is + /// erased by the time the binder runs — NonEmptyString is no different from string + /// here. The invariant holds for every value the type can hold; it cannot stop the binder + /// assigning none at all. Prefer an absent key, or validate on start. + /// + [Theory] + [MemberData(nameof(Paths))] + public void ExplicitNull_DefeatsANonNullableReferenceWrapper(BindingPath path) => + Assert.Null(Bind(path, "\"Name\": null").Name); + + // ── Empty string: not uniform, and not obvious ────────────────────── + + /// Empty is not a legal , and being declared nullable does not make it one — our converter is handed "" either way, because a nullable reference is the same runtime type. + [Theory] + [InlineData(BindingPath.ConfigurationGet, "\"Name\": \"\"")] + [InlineData(BindingPath.ConfigurationGet, "\"Name\": \" \"")] + [InlineData(BindingPath.ConfigurationGet, "\"Nickname\": \"\"")] + [InlineData(BindingPath.ConfigurationGet, "\"Nickname\": \" \"")] + [InlineData(BindingPath.Options, "\"Name\": \"\"")] + [InlineData(BindingPath.Options, "\"Name\": \" \"")] + [InlineData(BindingPath.Options, "\"Nickname\": \"\"")] + [InlineData(BindingPath.Options, "\"Nickname\": \" \"")] + public void EmptyString_OnAReferenceWrapper_Throws_NullableOrNot(BindingPath path, string settings) => + Assert.Throws(() => Bind(path, settings)); + + [Theory] + [MemberData(nameof(Paths))] + public void EmptyString_OnANonNullableStructWrapper_Throws(BindingPath path) => + Assert.Throws(() => Bind(path, "\"MaxRetries\": \"\"")); + + /// + /// The one asymmetry: empty binds to null here rather than throwing, because a nullable + /// struct resolves to the BCL's NullableConverter, which maps empty to null before our + /// converter is consulted. WPF, which unwraps Nullable<T> itself, does not do this — + /// the same "" raises a validation error there. + /// + [Theory] + [MemberData(nameof(Paths))] + public void EmptyString_OnANullableStructWrapper_IsNull(BindingPath path) => + Assert.Null(Bind(path, "\"Score\": \"\"").Score); + + // ── Invalid configuration fails, and says why ─────────────────────── + + [Theory] + [InlineData(BindingPath.ConfigurationGet, "\"MaxRetries\": -5", "MaxRetries", "-5", "must be positive")] + [InlineData(BindingPath.ConfigurationGet, "\"MaxRetries\": 0", "MaxRetries", "0", "must be positive")] + [InlineData(BindingPath.ConfigurationGet, "\"Score\": -1", "Score", "-1", "must be positive")] + [InlineData(BindingPath.ConfigurationGet, "\"InitialDelaySeconds\": -1", "InitialDelaySeconds", "-1", "must be non-negative")] + [InlineData(BindingPath.ConfigurationGet, "\"Contact\": \"not-an-email\"", "Contact", "not-an-email", "valid email")] + [InlineData(BindingPath.ConfigurationGet, "\"Tier\": 42", "Tier", "42", "single decimal digit")] + [InlineData(BindingPath.Options, "\"MaxRetries\": -5", "MaxRetries", "-5", "must be positive")] + [InlineData(BindingPath.Options, "\"MaxRetries\": 0", "MaxRetries", "0", "must be positive")] + [InlineData(BindingPath.Options, "\"Score\": -1", "Score", "-1", "must be positive")] + [InlineData(BindingPath.Options, "\"InitialDelaySeconds\": -1", "InitialDelaySeconds", "-1", "must be non-negative")] + [InlineData(BindingPath.Options, "\"Contact\": \"not-an-email\"", "Contact", "not-an-email", "valid email")] + [InlineData(BindingPath.Options, "\"Tier\": 42", "Tier", "42", "single decimal digit")] + public void ValueBreakingTheInvariant_ThrowsAndNamesTheReason( + BindingPath path, string settings, string key, string value, string expectedReason) + { + var exception = Assert.Throws(() => Bind(path, settings)); + + Assert.Contains($"Retry:{key}", exception.Message, StringComparison.Ordinal); + Assert.Contains(value, exception.Message, StringComparison.Ordinal); + + var inner = Assert.IsType(exception.InnerException); + Assert.Contains(expectedReason, inner.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(BindingPath.ConfigurationGet, "\"MaxRetries\": \"not-a-number\"")] + [InlineData(BindingPath.ConfigurationGet, "\"Tier\": \"not-a-digit\"")] + [InlineData(BindingPath.Options, "\"MaxRetries\": \"not-a-number\"")] + [InlineData(BindingPath.Options, "\"Tier\": \"not-a-digit\"")] + public void ValueInTheWrongFormat_Throws(BindingPath path, string settings) => + Assert.Throws(() => Bind(path, settings)); + + // ── When the failure surfaces ─────────────────────────────────────── + + /// + /// Binding is lazy: the failure surfaces on first IOptions.Value, not at + /// BuildServiceProvider. Callers wanting a startup failure add ValidateOnStart. + /// + [Fact] + public void InvalidValue_DoesNotThrowUntilOptionsAreRead() + { + var services = new ServiceCollection(); + services.AddOptions().Bind(Config("\"MaxRetries\": -5").GetSection("Retry")); + + var provider = services.BuildServiceProvider(); + var accessor = provider.GetRequiredService>(); + + Assert.Throws(() => accessor.Value); + } +} diff --git a/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs b/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs deleted file mode 100644 index 9477cb74..00000000 --- a/src/StrongTypes.Tests/ComponentModel/OptionsBindingTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Collections.Generic; -using FsCheck.Xunit; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Xunit; - -namespace StrongTypes.Tests; - -/// Without a TypeConverter, ConfigurationBinder cannot turn "5" into a strong type and silently leaves the property at its default rather than failing — so these assert the bound value, not merely that binding returned. -public class OptionsBindingTests -{ - private sealed class RetryOptions - { - public Positive MaxRetries { get; set; } - public NonNegative InitialDelaySeconds { get; set; } - public NonEmptyString? Name { get; set; } - public Email? Contact { get; set; } - public Digit Tier { get; set; } - public Positive? OptionalLimit { get; set; } - } - - private static IConfiguration Config(params (string Key, string Value)[] settings) - { - var values = new Dictionary(); - foreach (var (key, value) in settings) - { - values[key] = value; - } - - return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); - } - - private static RetryOptions BindViaOptions(IConfiguration configuration) - { - var services = new ServiceCollection(); - services.AddOptions().Bind(configuration.GetSection("Retry")); - return services.BuildServiceProvider().GetRequiredService>().Value; - } - - // ── The scenario the converter unlocks ────────────────────────────── - - [Fact] - public void EveryStrongTypeBindsFromConfiguration() - { - var options = BindViaOptions(Config( - ("Retry:MaxRetries", "5"), - ("Retry:InitialDelaySeconds", "0"), - ("Retry:Name", "checkout"), - ("Retry:Contact", "ops@example.com"), - ("Retry:Tier", "7"), - ("Retry:OptionalLimit", "99"))); - - Assert.Equal(5, options.MaxRetries.Value); - Assert.Equal(0, options.InitialDelaySeconds.Value); - Assert.Equal("checkout", options.Name?.Value); - Assert.Equal("ops@example.com", options.Contact?.Address); - Assert.Equal(7, options.Tier.Value); - Assert.Equal(99, options.OptionalLimit?.Value); - } - - /// default(Positive<int>) is a plausible-looking 1, so a dropped config value reads as a real one — hence asserting the bound value differs from the default. - [Property] - public void BoundValueComesFromConfiguration_NotTheDefault(int configured) - { - if (configured <= 0 || configured == default(Positive).Value) return; - - var options = BindViaOptions(Config(("Retry:MaxRetries", configured.ToString()))); - - Assert.Equal(configured, options.MaxRetries.Value); - Assert.NotEqual(default(Positive).Value, options.MaxRetries.Value); - } - - [Fact] - public void AbsentKeyLeavesTheDefault() - { - var options = BindViaOptions(Config(("Retry:Name", "checkout"))); - - Assert.Equal(default(Positive).Value, options.MaxRetries.Value); - Assert.Null(options.OptionalLimit); - } - - // ── Invalid configuration fails, and says why ─────────────────────── - - [Theory] - [InlineData("Retry:MaxRetries", "-5", "must be positive")] - [InlineData("Retry:MaxRetries", "0", "must be positive")] - [InlineData("Retry:InitialDelaySeconds", "-1", "must be non-negative")] - [InlineData("Retry:Name", " ", "whitespace")] - [InlineData("Retry:Contact", "not-an-email", "valid email")] - [InlineData("Retry:Tier", "42", "single decimal digit")] - public void ValueBreakingTheInvariant_ThrowsAndNamesTheReason(string key, string value, string expectedReason) - { - var exception = Assert.Throws(() => BindViaOptions(Config((key, value)))); - - Assert.Contains(key, exception.Message, StringComparison.Ordinal); - Assert.Contains(value, exception.Message, StringComparison.Ordinal); - - var inner = Assert.IsType(exception.InnerException); - Assert.Contains(expectedReason, inner.Message, StringComparison.OrdinalIgnoreCase); - } - - [Theory] - [InlineData("Retry:MaxRetries", "not-a-number")] - [InlineData("Retry:Tier", "not-a-digit")] - public void ValueInTheWrongFormat_Throws(string key, string value) - { - var exception = Assert.Throws(() => BindViaOptions(Config((key, value)))); - Assert.Contains(key, exception.Message, StringComparison.Ordinal); - } - - /// A nullable wrapper still enforces the invariant when a value is present. - [Fact] - public void NullableWrapper_InvalidValue_Throws() => - Assert.Throws(() => BindViaOptions(Config(("Retry:OptionalLimit", "-1")))); - - /// - /// Binding is lazy: the failure surfaces on first IOptions.Value, not at - /// BuildServiceProvider. Callers wanting startup failure add ValidateOnStart. - /// - [Fact] - public void InvalidValue_DoesNotThrowUntilOptionsAreRead() - { - var services = new ServiceCollection(); - services.AddOptions().Bind(Config(("Retry:MaxRetries", "-5")).GetSection("Retry")); - - var provider = services.BuildServiceProvider(); - var accessor = provider.GetRequiredService>(); - - Assert.Throws(() => accessor.Value); - } -} diff --git a/src/StrongTypes.Tests/StrongTypes.Tests.csproj b/src/StrongTypes.Tests/StrongTypes.Tests.csproj index 2b11725a..257bef51 100644 --- a/src/StrongTypes.Tests/StrongTypes.Tests.csproj +++ b/src/StrongTypes.Tests/StrongTypes.Tests.csproj @@ -13,6 +13,7 @@ + From 77eb4f35189c5745ecfa7bb3ea083217def160e7 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 08:06:04 +0200 Subject: [PATCH 06/24] Pin what an unconfigured strong type actually holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binding tests used an options class with property initialisers, which hid the ordinary case: a real options class has none, so an unconfigured NonEmptyString is null. A wrapper's invariant constrains every value it can hold and cannot make the binder assign one — against a missing key it is no better than a string. ValidateOnStart() does not help: it forces eager binding, and binding of an absent key succeeds without assigning, so nothing is raised. The docs recommended it as though it were the guard for this. [Required] plus ValidateDataAnnotations() is what catches a missing value. And [Required] cannot catch a missing non-nullable struct wrapper at all: default(Positive) is 1, a real invariant-satisfying value, so it never looks absent. Verified — the failures name Name and MaxRetriesOrUnset but not MaxRetries. A struct wrapper must be declared nullable for absence to be detectable. Co-Authored-By: Claude Opus 4.8 --- Skill/references/configuration.md | 40 +++++- readme.md | 13 +- .../ConfigurationBindingTests.cs | 3 +- .../UnconfiguredOptionsTests.cs | 121 ++++++++++++++++++ .../StrongTypes.Tests.csproj | 1 + 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index a3caf9e7..66f1bf96 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -65,6 +65,41 @@ even with **no** `IValidateOptions` registered. Always add it when options hold strong types — otherwise the invariant buys you a later crash, not an earlier one. +## `ValidateOnStart()` does not catch a *missing* value + +It only surfaces failures that binding itself raises. A key that simply isn't +there raises nothing — binding succeeds, it just doesn't assign — so the +property keeps whatever the options class gave it. Since a real options class +has no initialisers, that means: + +| declaration | key not in config | `[Required]` catches it? | +| ------------------------------- | ----------------- | ------------------------ | +| `NonEmptyString Name` | **`null`** | yes | +| `Positive MaxRetries` | **`1`** (default) | **no** | +| `Positive? MaxRetries` | `null` | yes | + +Two consequences worth internalising: + +- **A non-nullable `NonEmptyString` can be null.** The invariant constrains + every value the type can hold; it cannot make the binder assign one. The + wrapper is no better than `string` at surviving an unconfigured key. +- **A non-nullable struct wrapper cannot be checked at all.** + `default(Positive)` is `1` — a real, invariant-satisfying value — so + `[Required]` passes and nothing distinguishes "configured as 1" from "never + configured". **Declare it `Positive?` when that distinction matters.** + +So the full guard is both: + +```csharp +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Retry")) + .ValidateDataAnnotations() // [Required] → catches a missing value + .ValidateOnStart(); // → catches an invalid one, at startup +``` + +…with `[Required]` on each property that must be present, and struct wrappers +declared nullable so `[Required]` has a null to find. + ## `null` and `""` — the exact matrix Not uniform, and not guessable. Measured; `ConfigurationBinder.Get` and @@ -72,13 +107,16 @@ Not uniform, and not guessable. Measured; `ConfigurationBinder.Get` and | in `appsettings.json` | `NonEmptyString` | `NonEmptyString?` | `Positive` | `Positive?` | | --------------------- | ---------------- | ----------------- | --------------- | ---------------- | -| key absent | default kept | `null` | `1` (`default`) | `null` | +| key absent | **`null`** † | `null` | `1` (`default`) | `null` | | `null` | **`null`** | `null` | `1` (`default`) | `null` | | `""` | **throws** | **throws** | **throws** | **`null`** | | `" "` | **throws** | **throws** | throws (format) | throws (format) | | valid | binds | binds | binds | binds | | invariant breach | throws | throws | throws | throws | +† unless the options class initialises the property, which is rare — an absent +key leaves whatever was already there, and for a reference type that is `null`. + Three things in there surprise people: - **`""` is not uniform.** It throws for everything *except* a nullable **struct** diff --git a/readme.md b/readme.md index 3a22937e..09c660ec 100644 --- a/readme.md +++ b/readme.md @@ -154,7 +154,8 @@ public sealed class RetryOptions builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Retry")) - .ValidateOnStart(); + .ValidateDataAnnotations() // [Required] → catches a missing value + .ValidateOnStart(); // → catches an invalid one, at startup ``` A bad value fails with the path, the value, and the reason — no `[Range]` and no `IValidateOptions` needed: @@ -165,9 +166,15 @@ InvalidOperationException: Failed to convert configuration value '-5' at 'Retry: ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') ``` -Reach for `ValidateOnStart()`: options binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. +Both guards earn their place, and they catch different things. `ValidateOnStart()` handles the *invalid* value: binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. `ValidateDataAnnotations()` with `[Required]` handles the *missing* one, which `ValidateOnStart()` alone will not: an absent key raises nothing, because binding succeeds and simply doesn't assign. -Two edges worth knowing, both inherited from `ConfigurationBinder` rather than introduced here. An explicit `"Name": null` nulls even a non-nullable `NonEmptyString` — nullability is erased by the time the binder runs, so omit the key instead of writing `null`. And `""` throws for every wrapper *except* a nullable struct one (`Positive?`), where the BCL's `NullableConverter` maps it to `null` first. The full matrix is in the [skill reference](Skill/references/configuration.md). +Three edges worth knowing, all inherited from `ConfigurationBinder` rather than introduced here: + +- **A wrapper doesn't survive an unconfigured key.** Its invariant constrains every value it can hold, but can't make the binder assign one — so an unconfigured `NonEmptyString Name` is `null`, exactly as a `string` would be. Use `[Required]`. +- **A non-nullable struct wrapper can't be checked for absence at all.** `default(Positive)` is `1` — a real, invariant-satisfying value — so `[Required]` passes and nothing tells "configured as 1" from "never configured". Declare it `Positive?` when that matters. +- **`null` and `""` are not interchangeable.** An explicit `"Name": null` nulls even a non-nullable `NonEmptyString`, so omit the key rather than writing `null`; and `""` throws for every wrapper *except* a nullable struct one, where the BCL's `NullableConverter` maps it to `null` first. + +The full matrix is in the [skill reference](Skill/references/configuration.md). [↑ Back to contents](#contents) diff --git a/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs b/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs index 724498ac..185b23c0 100644 --- a/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs +++ b/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs @@ -99,9 +99,10 @@ public void BoundValueComesFromConfiguration_NotTheDefault(int configured) // ── Absent vs. explicit null ──────────────────────────────────────── + /// An absent key leaves the property exactly as the options class constructed it — here a non-null initialiser. A real options class rarely has one; see for what absence yields then. [Theory] [MemberData(nameof(Paths))] - public void AbsentKey_LeavesTheDefault(BindingPath path) + public void AbsentKey_LeavesWhateverThePropertyAlreadyHeld(BindingPath path) { var options = Bind(path, "\"Tier\": 7"); diff --git a/src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs b/src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs new file mode 100644 index 00000000..5722c06a --- /dev/null +++ b/src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs @@ -0,0 +1,121 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace StrongTypes.Tests; + +/// +/// What a strong type holds when its key is simply not in config. A wrapper's invariant constrains +/// every value it can hold; it cannot make the binder assign one, so an unconfigured property is a +/// hole the type system does not plug — and the two kinds of wrapper leave a different shape of hole. +/// +/// +/// The options classes here deliberately carry no property initialisers. An initialiser hides the +/// whole problem: the binder leaves it in place and the property looks configured. +/// +public class UnconfiguredOptionsTests +{ + private sealed class RetryOptions + { + public NonEmptyString Name { get; set; } = null!; + public Positive MaxRetries { get; set; } + public Positive? MaxRetriesOrUnset { get; set; } + public Digit Tier { get; set; } + } + + private sealed class RequiredRetryOptions + { + [Required] public NonEmptyString Name { get; set; } = null!; + [Required] public Positive MaxRetries { get; set; } + [Required] public Positive? MaxRetriesOrUnset { get; set; } + public Digit Tier { get; set; } + } + + /// Only Tier is configured; the section exists so the binder produces an instance. + private const string OnlyTierConfigured = "{\"Retry\":{\"Tier\":7}}"; + + private static IConfigurationSection Section(string json) => + new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))) + .Build() + .GetSection("Retry"); + + private static TOptions Bind(string json, bool validate = false) where TOptions : class + { + var services = new ServiceCollection(); + var builder = services.AddOptions().Bind(Section(json)); + if (validate) builder.ValidateDataAnnotations(); + return services.BuildServiceProvider().GetRequiredService>().Value; + } + + /// A non-nullable reference wrapper is null when unconfigured — nullability is erased before the binder runs, so it behaves exactly as a would. + [Fact] + public void UnconfiguredReferenceWrapper_IsNull() => + Assert.Null(Bind(OnlyTierConfigured).Name); + + /// A struct wrapper falls back to default, which satisfies the invariant and so reads as a deliberately configured value. This is the shape the silent-config bug took. + [Fact] + public void UnconfiguredStructWrapper_IsItsDefault() + { + var options = Bind(OnlyTierConfigured); + + Assert.Equal(default(Positive).Value, options.MaxRetries.Value); + Assert.Equal(1, options.MaxRetries.Value); + Assert.Equal(7, options.Tier.Value); + } + + [Fact] + public void UnconfiguredNullableStructWrapper_IsNull() => + Assert.Null(Bind(OnlyTierConfigured).MaxRetriesOrUnset); + + /// Binding alone raises nothing for a missing key, whatever the property's nullability says. + [Fact] + public void MissingKeys_BindWithoutError() + { + var options = Bind(OnlyTierConfigured); + + Assert.Null(options.Name); + Assert.Equal(1, options.MaxRetries.Value); + } + + /// + /// [Required] plus ValidateDataAnnotations() catches a missing reference wrapper + /// and a missing nullable struct wrapper — both are null — but cannot catch a missing + /// non-nullable struct wrapper, whose default is an ordinary value and never null. Declare a + /// struct wrapper nullable when "not configured" has to be detectable. + /// + [Fact] + public void Required_CatchesNullsOnly_NotAStructsDefault() + { + var exception = Assert.Throws( + () => Bind(OnlyTierConfigured, validate: true)); + + var failures = string.Join(" | ", exception.Failures); + + Assert.Contains($"'{nameof(RequiredRetryOptions.Name)}'", failures, System.StringComparison.Ordinal); + Assert.Contains($"'{nameof(RequiredRetryOptions.MaxRetriesOrUnset)}'", failures, System.StringComparison.Ordinal); + Assert.DoesNotContain($"'{nameof(RequiredRetryOptions.MaxRetries)}'", failures, System.StringComparison.Ordinal); + } + + /// Every key present and valid: validation passes, so the failures above are about absence rather than the annotations themselves. + [Fact] + public void Required_FullyConfigured_Passes() + { + var options = Bind( + "{\"Retry\":{\"Name\":\"checkout\",\"MaxRetries\":5,\"MaxRetriesOrUnset\":9,\"Tier\":7}}", + validate: true); + + Assert.Equal("checkout", options.Name.Value); + Assert.Equal(5, options.MaxRetries.Value); + Assert.Equal(9, options.MaxRetriesOrUnset?.Value); + } + + /// Get<T> on a section with no children at all returns null rather than a defaulted instance. + [Fact] + public void EmptySection_GetReturnsNull() => + Assert.Null(Section("{\"Retry\":{}}").Get()); +} diff --git a/src/StrongTypes.Tests/StrongTypes.Tests.csproj b/src/StrongTypes.Tests/StrongTypes.Tests.csproj index 257bef51..7ea5f1b5 100644 --- a/src/StrongTypes.Tests/StrongTypes.Tests.csproj +++ b/src/StrongTypes.Tests/StrongTypes.Tests.csproj @@ -16,6 +16,7 @@ + From c5ddd5bdde4fcae8da81f593e87d46dfeec5b7bd Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 08:31:34 +0200 Subject: [PATCH 07/24] Add Kalicz.StrongTypes.Configuration with BindStrongTypes() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrapper's invariant constrains every value it can hold; it cannot make the binder assign one, because the binder reaches in through reflection and never calls Create. So an unconfigured NonEmptyString is null and an unconfigured Positive is 1. Neither existing guard closes that. ValidateOnStart() only surfaces failures binding raises, and binding an absent key succeeds without assigning. [Required] finds the null NonEmptyString but cannot find MaxRetries at all — default(Positive) is 1, an ordinary invariant-satisfying value. C#'s required keyword is compile-time only and the binder walks past it. BindStrongTypes asks the configuration whether the key is present rather than asking the bound object whether it looks null, so the struct case has an answer. Required-ness comes from the declaration already written — Positive vs Positive? — read via NullabilityInfoContext, so no attribute restates what the ? already said. Its own package: this needs Microsoft.Extensions.Options.ConfigurationExtensions and core has zero PackageReferences, which is worth keeping — a domain library referencing Kalicz.StrongTypes should not acquire the configuration stack. Only our wrappers are policed; plain string/int properties are left to whatever validation the caller already has. An options class compiled without nullable reference types declares no intent for a reference wrapper, so it is treated as optional rather than guessed at — pinned by a deliberately Nullable-disabled test project, since that degradation is a real wart and should not be folklore. Co-Authored-By: Claude Opus 4.8 --- StrongTypes.slnx | 5 + ...onfiguration.Tests.NullableDisabled.csproj | 17 ++ .../UnannotatedOptions.cs | 9 + .../BindStrongTypesTests.cs | 185 ++++++++++++++++++ .../StrongTypes.Configuration.Tests.csproj | 26 +++ .../OptionsBuilderExtensions.cs | 40 ++++ .../RequiredStrongTypeKeysValidator.cs | 60 ++++++ .../StrongTypeProperty.cs | 47 +++++ .../StrongTypes.Configuration.csproj | 34 ++++ src/StrongTypes.Configuration/readme.md | 74 +++++++ 10 files changed, 497 insertions(+) create mode 100644 src/StrongTypes.Configuration.Tests.NullableDisabled/StrongTypes.Configuration.Tests.NullableDisabled.csproj create mode 100644 src/StrongTypes.Configuration.Tests.NullableDisabled/UnannotatedOptions.cs create mode 100644 src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs create mode 100644 src/StrongTypes.Configuration.Tests/StrongTypes.Configuration.Tests.csproj create mode 100644 src/StrongTypes.Configuration/OptionsBuilderExtensions.cs create mode 100644 src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs create mode 100644 src/StrongTypes.Configuration/StrongTypeProperty.cs create mode 100644 src/StrongTypes.Configuration/StrongTypes.Configuration.csproj create mode 100644 src/StrongTypes.Configuration/readme.md diff --git a/StrongTypes.slnx b/StrongTypes.slnx index 8b559a24..5aeda2f2 100644 --- a/StrongTypes.slnx +++ b/StrongTypes.slnx @@ -8,6 +8,11 @@ + + + + + diff --git a/src/StrongTypes.Configuration.Tests.NullableDisabled/StrongTypes.Configuration.Tests.NullableDisabled.csproj b/src/StrongTypes.Configuration.Tests.NullableDisabled/StrongTypes.Configuration.Tests.NullableDisabled.csproj new file mode 100644 index 00000000..9429db79 --- /dev/null +++ b/src/StrongTypes.Configuration.Tests.NullableDisabled/StrongTypes.Configuration.Tests.NullableDisabled.csproj @@ -0,0 +1,17 @@ + + + net10.0 + 14.0 + + disable + enable + true + CS1591 + false + + + + + diff --git a/src/StrongTypes.Configuration.Tests.NullableDisabled/UnannotatedOptions.cs b/src/StrongTypes.Configuration.Tests.NullableDisabled/UnannotatedOptions.cs new file mode 100644 index 00000000..e047c1d0 --- /dev/null +++ b/src/StrongTypes.Configuration.Tests.NullableDisabled/UnannotatedOptions.cs @@ -0,0 +1,9 @@ +namespace StrongTypes.Configuration.Tests.NullableDisabled; + +/// An options class from an assembly with no nullable annotations: Name declares no intent either way, while MaxRetries and Score still differ in the type itself. +public sealed class UnannotatedOptions +{ + public NonEmptyString Name { get; set; } + public Positive MaxRetries { get; set; } + public Positive? Score { get; set; } +} diff --git a/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs new file mode 100644 index 00000000..d1bd73cc --- /dev/null +++ b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs @@ -0,0 +1,185 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StrongTypes.Configuration.Tests.NullableDisabled; +using Xunit; + +namespace StrongTypes.Configuration.Tests; + +/// +/// BindStrongTypes exists for the key that isn't there. Binding an absent key succeeds +/// without assigning, so nothing is raised and the property keeps a default — null for a +/// reference wrapper, and for a struct wrapper an ordinary value that no [Required] can tell +/// from a configured one. +/// +public class BindStrongTypesTests +{ + private sealed class RetryOptions + { + public NonEmptyString Name { get; set; } = null!; + public NonEmptyString? Nickname { get; set; } + public Positive MaxRetries { get; set; } + public Positive? Score { get; set; } + public Digit Tier { get; set; } + public string PlainString { get; set; } = null!; + public int PlainInt { get; set; } + } + + private const string FullyConfigured = """ + { "Retry": { "Name": "checkout", "MaxRetries": 5, "Tier": 7 } } + """; + + private static IConfigurationSection Section(string json) => + new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))) + .Build() + .GetSection("Retry"); + + private static TOptions Bind(string json) where TOptions : class + { + var services = new ServiceCollection(); + services.AddOptions().BindStrongTypes(Section(json)); + return services.BuildServiceProvider().GetRequiredService>().Value; + } + + private static OptionsValidationException BindExpectingFailure(string json) where TOptions : class => + Assert.Throws(() => Bind(json)); + + // ── The gap it closes ─────────────────────────────────────────────── + + /// The contrast, and the whole reason the package exists: plain Bind takes the same config without a murmur, leaving a null NonEmptyString and a MaxRetries of 1 that reads as deliberate. + [Fact] + public void PlainBind_AcceptsTheSameMissingKeysSilently() + { + var services = new ServiceCollection(); + services.AddOptions().Bind(Section("""{ "Retry": { "Tier": 7 } }""")); + + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + Assert.Null(options.Name); + Assert.Equal(1, options.MaxRetries.Value); + } + + /// The case that motivated the package: default(Positive<int>) is 1, so nothing about the bound object says "never configured". + [Fact] + public void MissingNonNullableStructWrapper_Fails() + { + var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout", "Tier": 7 } }"""); + + Assert.Contains("'Retry:MaxRetries' is required but was not configured", string.Join(" | ", exception.Failures), StringComparison.Ordinal); + } + + [Fact] + public void MissingNonNullableReferenceWrapper_Fails() + { + var exception = BindExpectingFailure("""{ "Retry": { "MaxRetries": 5, "Tier": 7 } }"""); + + Assert.Contains("'Retry:Name' is required but was not configured", string.Join(" | ", exception.Failures), StringComparison.Ordinal); + } + + [Fact] + public void EveryMissingKey_IsReportedTogether() + { + var exception = BindExpectingFailure("""{ "Retry": { "Tier": 7 } }"""); + + var failures = string.Join(" | ", exception.Failures); + Assert.Contains("Retry:Name", failures, StringComparison.Ordinal); + Assert.Contains("Retry:MaxRetries", failures, StringComparison.Ordinal); + } + + /// The failure has to name the config path, not the property — that is what the reader has to go and edit. + [Fact] + public void Failure_NamesTheConfigurationPathAndTheFix() + { + var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout", "Tier": 7 } }"""); + var failure = Assert.Single(exception.Failures); + + Assert.Contains("Retry:MaxRetries", failure, StringComparison.Ordinal); + Assert.Contains("nullable if it is optional", failure, StringComparison.Ordinal); + } + + // ── What it must not do ───────────────────────────────────────────── + + [Fact] + public void FullyConfigured_Binds() + { + var options = Bind(FullyConfigured); + + Assert.Equal("checkout", options.Name.Value); + Assert.Equal(5, options.MaxRetries.Value); + Assert.Equal(7, options.Tier.Value); + } + + [Fact] + public void MissingNullableWrappers_DoNotFail() + { + var options = Bind(FullyConfigured); + + Assert.Null(options.Nickname); + Assert.Null(options.Score); + } + + /// Only our wrappers are policed; an unconfigured string or int is left to whatever validation the caller already has. + [Fact] + public void MissingPlainProperties_AreNotPoliced() + { + var options = Bind(FullyConfigured); + + Assert.Null(options.PlainString); + Assert.Equal(0, options.PlainInt); + } + + /// Presence is the only question asked here; an invalid value is still the converter's failure, thrown while binding. + [Fact] + public void PresentButInvalidValue_StillThrowsTheInvariantFailure() + { + var exception = Assert.Throws( + () => Bind("""{ "Retry": { "Name": "checkout", "MaxRetries": -5, "Tier": 7 } }""")); + + // Assert.Throws is exact-type, so reaching here already rules out a validation failure. + Assert.IsType(exception.InnerException); + } + + // ── Named options ─────────────────────────────────────────────────── + + [Fact] + public void NamedOptions_ValidateOnlyTheirOwnName() + { + var services = new ServiceCollection(); + services.AddOptions("configured").BindStrongTypes(Section(FullyConfigured)); + services.AddOptions("incomplete").BindStrongTypes(Section("""{ "Retry": { "Tier": 7 } }""")); + + var monitor = services.BuildServiceProvider().GetRequiredService>(); + + Assert.Equal("checkout", monitor.Get("configured").Name.Value); + Assert.Throws(() => monitor.Get("incomplete")); + } + + // ── Nullable reference types disabled ─────────────────────────────── + + /// + /// An options class from an assembly compiled without nullable reference types declares no + /// intent for a reference wrapper, so it is treated as optional rather than guessed at. The + /// struct wrapper still carries its own distinction and is still required. + /// + [Fact] + public void UnannotatedAssembly_ReferenceWrapperIsOptional_StructWrapperIsStillRequired() + { + var exception = BindExpectingFailure("""{ "Retry": { "Tier": 7 } }"""); + var failures = string.Join(" | ", exception.Failures); + + Assert.Contains("Retry:MaxRetries", failures, StringComparison.Ordinal); + Assert.DoesNotContain("Retry:Name", failures, StringComparison.Ordinal); + } + + // ── Guard rails ───────────────────────────────────────────────────── + + [Fact] + public void NullSection_Throws() + { + var builder = new ServiceCollection().AddOptions(); + + Assert.Throws(() => builder.BindStrongTypes(null!)); + } +} diff --git a/src/StrongTypes.Configuration.Tests/StrongTypes.Configuration.Tests.csproj b/src/StrongTypes.Configuration.Tests/StrongTypes.Configuration.Tests.csproj new file mode 100644 index 00000000..6b16df71 --- /dev/null +++ b/src/StrongTypes.Configuration.Tests/StrongTypes.Configuration.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + Exe + 14.0 + enable + true + CS1591 + enable + false + true + true + + + + + + + + + + + + + + diff --git a/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs b/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs new file mode 100644 index 00000000..3789f7ae --- /dev/null +++ b/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace StrongTypes.Configuration; + +public static class OptionsBuilderExtensions +{ + /// Binds to and requires a configuration key for every non-nullable strong-type property on it. + /// The options builder to bind. + /// The configuration section to bind from. + /// , for chaining — pair with ValidateOnStart() to fail the host rather than the first request that reads the options. + /// + /// + /// A wrapper's invariant constrains every value it can hold; it cannot make the binder assign + /// one. An unconfigured NonEmptyString is therefore null, and an unconfigured + /// Positive<int> is 1 — its default, an ordinary value that no [Required] + /// can distinguish from a configured one. This checks the section for the key instead of + /// checking the bound object for a null, so both cases fail. + /// + /// + /// The declaration is the spec: Positive<int> is required, Positive<int>? + /// is optional. Only Kalicz.StrongTypes wrappers are checked. A property in an assembly compiled + /// without nullable reference types carries no annotation and is treated as optional. + /// + /// + /// or is null. + public static OptionsBuilder BindStrongTypes(this OptionsBuilder builder, IConfiguration section) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(section); + + builder.Bind(section); + builder.Services.AddSingleton>( + new RequiredStrongTypeKeysValidator(builder.Name, section)); + + return builder; + } +} diff --git a/src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs b/src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs new file mode 100644 index 00000000..83e8ec67 --- /dev/null +++ b/src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs @@ -0,0 +1,60 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace StrongTypes.Configuration; + +/// Fails validation when a non-nullable strong-type property on has no key in the bound section. +/// The options type being bound. +internal sealed class RequiredStrongTypeKeysValidator(string? name, IConfiguration section) : IValidateOptions + where TOptions : class +{ + private static readonly NullabilityInfoContext NullabilityContext = new(); + private static readonly Lock NullabilityGate = new(); + + public ValidateOptionsResult Validate(string? optionsName, TOptions options) + { + // A named registration validates only its own name; Configure(null) covers all. + if (name is not null && name != optionsName) + { + return ValidateOptionsResult.Skip; + } + + List? failures = null; + + foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetSetMethod() is null || property.GetIndexParameters().Length > 0) + { + continue; + } + if (!StrongTypeProperty.IsStrongType(property.PropertyType) || StrongTypeProperty.IsOptional(property, Nullability)) + { + continue; + } + if (section.GetSection(property.Name).Value is not null) + { + continue; + } + + failures ??= []; + failures.Add($"'{Path(property.Name)}' is required but was not configured. Declare {typeof(TOptions).Name}.{property.Name} nullable if it is optional."); + } + + return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + private string Path(string propertyName) => + string.IsNullOrEmpty(section is IConfigurationSection s ? s.Path : null) + ? propertyName + : $"{((IConfigurationSection)section).Path}:{propertyName}"; + + /// is documented as not thread-safe, and options validation can run concurrently. + private static NullabilityInfo Nullability(PropertyInfo property) + { + lock (NullabilityGate) + { + return NullabilityContext.Create(property); + } + } +} diff --git a/src/StrongTypes.Configuration/StrongTypeProperty.cs b/src/StrongTypes.Configuration/StrongTypeProperty.cs new file mode 100644 index 00000000..2b3e3bdb --- /dev/null +++ b/src/StrongTypes.Configuration/StrongTypeProperty.cs @@ -0,0 +1,47 @@ +using System.Reflection; + +namespace StrongTypes.Configuration; + +internal static class StrongTypeProperty +{ + private static readonly HashSet StrongTypeDefinitions = + [ + typeof(NonEmptyString), + typeof(Email), + typeof(Digit), + typeof(Positive<>), + typeof(NonNegative<>), + typeof(Negative<>), + typeof(NonPositive<>), + ]; + + public static bool IsStrongType(Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + return StrongTypeDefinitions.Contains(underlying.IsGenericType ? underlying.GetGenericTypeDefinition() : underlying); + } + + /// + /// True when the declaration says the value may be absent: Positive<int>?, or a + /// reference wrapper the assembly annotated as nullable. + /// + /// + /// An assembly compiled without nullable reference types carries no annotation, so a reference + /// wrapper reads as . That is treated as optional: with + /// nothing declared there is no intent to enforce, and failing would make the package unusable + /// for those projects. + /// + public static bool IsOptional(PropertyInfo property, Func nullability) + { + if (Nullable.GetUnderlyingType(property.PropertyType) is not null) + { + return true; + } + if (property.PropertyType.IsValueType) + { + return false; + } + + return nullability(property).WriteState != NullabilityState.NotNull; + } +} diff --git a/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj b/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj new file mode 100644 index 00000000..bd7843d6 --- /dev/null +++ b/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj @@ -0,0 +1,34 @@ + + + net10.0 + 14.0 + enable + enable + true + CS1591 + + 0.0.0-dev + Kalicz.StrongTypes.Configuration + KaliCZ + Copyright © 2026 KaliCZ + Configuration binding support for Kalicz.StrongTypes. Adds OptionsBuilder<T>.BindStrongTypes(), which binds a section and then fails when a non-nullable strong-type property has no configuration key — the case [Required] cannot catch for struct wrappers, whose default is an ordinary value rather than null. + StrongTypes, Configuration, Options, IOptions, Validation, NonEmptyString + MIT + false + https://github.com/KaliCZ/StrongTypes + git + https://github.com/KaliCZ/StrongTypes.git + readme.md + true + true + + + + + + + + + + + diff --git a/src/StrongTypes.Configuration/readme.md b/src/StrongTypes.Configuration/readme.md new file mode 100644 index 00000000..d30ed0e7 --- /dev/null +++ b/src/StrongTypes.Configuration/readme.md @@ -0,0 +1,74 @@ +# Kalicz.StrongTypes.Configuration + +[![NuGet version](https://img.shields.io/nuget/v/Kalicz.StrongTypes.Configuration?label=nuget)](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) [![Downloads](https://img.shields.io/nuget/dt/Kalicz.StrongTypes.Configuration?label=downloads)](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) [![License](https://img.shields.io/github/license/KaliCZ/StrongTypes)](https://github.com/KaliCZ/StrongTypes/blob/main/license.txt) + +Makes an **unconfigured** strong type fail instead of quietly defaulting. + +```csharp +builder.Services.AddOptions() + .BindStrongTypes(builder.Configuration.GetSection("Retry")) + .ValidateOnStart(); +``` + +## The problem + +[`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) needs no package to +bind: every wrapper carries a `TypeConverter`, so values bind and invalid ones throw with the +invariant's own message. **This package is only about the key that isn't there.** + +A wrapper's invariant constrains every value it can hold. It cannot make the binder assign one — the +binder reaches in through reflection and never calls `Create`. So: + +```csharp +public sealed class RetryOptions +{ + public NonEmptyString Name { get; set; } // unconfigured -> null + public Positive MaxRetries { get; set; } // unconfigured -> 1 +} +``` + +`ValidateOnStart()` does not help: binding an absent key *succeeds*, it just doesn't assign, so +nothing is raised. `[Required]` catches `Name`, because it is null — but **cannot** catch +`MaxRetries`, because `default(Positive)` is `1`, an ordinary invariant-satisfying value that +looks exactly like a configured one. Neither does C#'s `required` keyword, which the binder's +reflection walks straight past. + +## What it does + +`BindStrongTypes()` binds the section, then asks **the configuration** whether each key is present +— rather than asking the bound object whether it looks null. That question has an answer for +structs: + +``` +OptionsValidationException: 'Retry:MaxRetries' is required but was not configured. + Declare RetryOptions.MaxRetries nullable if it is optional. +``` + +The declaration is the spec — no attributes: + +| declaration | meaning | +| --------------------------- | ----------- | +| `Positive MaxRetries` | required | +| `Positive? MaxRetries` | optional | +| `NonEmptyString Name` | required | +| `NonEmptyString? Nickname` | optional | + +Only Kalicz.StrongTypes wrappers are checked; a `string` or `int` property is left to whatever +validation you already use. + +Pair it with `ValidateOnStart()`. On its own the failure is still lazy — it surfaces on the first +read of `IOptions.Value`, not at startup. + +## When you don't need it + +- No strong types in your options classes. +- Every strong-typed property is nullable, so nothing is required. +- You already have an `IValidateOptions` covering presence. + +## Nullable reference types + +Required-ness for a reference wrapper is read from the assembly's nullable annotations. An options +class in a project with `disable` carries none, so its reference wrappers are +treated as **optional** — with no intent declared there is nothing to enforce. Struct wrappers +(`Positive` vs `Positive?`) are unaffected: that distinction is in the type itself and +always readable. From 230c1e86b2ac6822ca74c0a43744a9e96cdf5e0f Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 08:43:29 +0200 Subject: [PATCH 08/24] Add ST0004 nudging strong-typed options onto BindStrongTypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires where a plain Bind/Configure binds an options type carrying a strong type that must be configured, and only where nothing else would notice: a struct wrapper, whose default is never null and so always satisfies [Required], or a reference wrapper with no [Required] at all. A reference wrapper already carrying [Required] is genuinely covered, so nagging there would be wrong. Like the EF Core and OpenAPI analyzers, this takes no dependency on what it detects — Options types resolve by metadata name from the consumer's compilation, and the analyzer no-ops when absent. Only the test project references the stack, to build a compilation containing it. Required-ness for a reference wrapper reads NullableAnnotation, mirroring the NullabilityInfoContext check BindStrongTypes makes at runtime; the test sources opt into #nullable enable because the harness does not enable it compilation-wide. The fix rewrites Bind to BindStrongTypes and is registered only when Kalicz.StrongTypes.Configuration is referenced — without it the rewrite would not compile. Configure gets the diagnostic but no fix: that rewrite means restructuring to AddOptions().BindStrongTypes(...), which is the caller's call. Source-editing fixes needed a new DocumentCodeFixTester; the existing CodeFixTester drives the csproj-writing package fixes, which are not observable as a document change. Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 3 +- Skill/SKILL.md | 2 + Skill/references/configuration.md | 28 +++ readme.md | 17 +- .../Infrastructure/DocumentCodeFixTester.cs | 82 ++++++++ .../Infrastructure/TestReferences.cs | 13 ++ .../StrongTypes.Analyzers.Tests.csproj | 3 + ...validatedStrongTypeOptionsAnalyzerTests.cs | 178 +++++++++++++++++ .../UseBindStrongTypesCodeFixProviderTests.cs | 86 ++++++++ .../AnalyzerReleases.Unshipped.md | 6 + .../UnvalidatedStrongTypeOptionsAnalyzer.cs | 188 ++++++++++++++++++ .../UseBindStrongTypesCodeFixProvider.cs | 113 +++++++++++ 12 files changed, 712 insertions(+), 7 deletions(-) create mode 100644 src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs create mode 100644 src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs create mode 100644 src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs create mode 100644 src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs create mode 100644 src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0e9aa52..8c4bb9ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,8 @@ as part of the same PR. spec, move the long form into the type's XML docs and leave a tight example in the readme. - **Package readmes** — each package that ships its own readme - ([`StrongTypes.EfCore`](src/StrongTypes.EfCore/readme.md), + ([`StrongTypes.Configuration`](src/StrongTypes.Configuration/readme.md), + [`StrongTypes.EfCore`](src/StrongTypes.EfCore/readme.md), [`StrongTypes.FsCheck`](src/StrongTypes.FsCheck/readme.md), [`StrongTypes.OpenApi.Microsoft`](src/StrongTypes.OpenApi.Microsoft/readme.md), [`StrongTypes.OpenApi.Swashbuckle`](src/StrongTypes.OpenApi.Swashbuckle/readme.md), diff --git a/Skill/SKILL.md b/Skill/SKILL.md index 052af5f3..bae59ace 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -20,6 +20,7 @@ demand when about to write code against that surface. | Package | What it gives you | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `Kalicz.StrongTypes` | The core types (`NonEmptyString`, numeric wrappers, `NonEmptyEnumerable`, `Maybe`, `Result`, …). | +| `Kalicz.StrongTypes.Configuration` | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a non-nullable strong-type property has no configuration key, which `[Required]` cannot do for struct wrappers (`default(Positive)` is `1`, never null). | | `Kalicz.StrongTypes.EfCore` | EF Core value converters, interval column mapping (two endpoint columns by default, JSON opt-in), and LINQ translators (`.Unwrap()`, interval `Start`/`End`) so strong types sit directly on entity properties. | | `Kalicz.StrongTypes.FsCheck` | FsCheck `Arbitrary` generators registered via `[Properties(Arbitrary = new[] { typeof(Generators) })]`. | | `Kalicz.StrongTypes.OpenApi.Microsoft` | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so wrappers render as the wire JSON shape, not the CLR shape. | @@ -28,6 +29,7 @@ demand when about to write code against that surface. Add packages only when the host project actually hits that stack: +- **Configuration** — when a strong type sits on an options class and must be configured. Binding works without it (every wrapper carries a `TypeConverter`); the package is only about the key that isn't there. Skip it if every strong-typed option is nullable. See `references/configuration.md`. - **EfCore** — only if EF Core is in use. - **FsCheck** — only for property-based test projects. - **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines. diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 66f1bf96..3a9202e8 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -65,6 +65,34 @@ even with **no** `IValidateOptions` registered. Always add it when options hold strong types — otherwise the invariant buys you a later crash, not an earlier one. +## The missing key — `BindStrongTypes()` + +Add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) +and the declaration becomes the spec — no `[Required]`, no `ValidateDataAnnotations()`: + +```csharp +builder.Services.AddOptions() + .BindStrongTypes(builder.Configuration.GetSection("Retry")) + .ValidateOnStart(); +``` + +``` +OptionsValidationException: 'Retry:MaxRetries' is required but was not configured. + Declare RetryOptions.MaxRetries nullable if it is optional. +``` + +`Positive` is required, `Positive?` is optional. It works where `[Required]` cannot +because it asks **configuration** whether the key is present, rather than asking the bound object +whether it looks null — and `default(Positive)` is `1`, which looks like a configured value. +Only Kalicz.StrongTypes wrappers are checked; `string`/`int` properties are left to whatever +validation you already have. + +Analyzer **ST0004** flags a plain `Bind` / `Configure` on an options type that needs this, with a +code fix that rewrites the call. It stays quiet for a reference wrapper already carrying +`[Required]`, since that case genuinely is covered. + +The rest of this section describes what happens **without** that package. + ## `ValidateOnStart()` does not catch a *missing* value It only surfaces failures that binding itself raises. A key that simply isn't diff --git a/readme.md b/readme.md index 09c660ec..85adf140 100644 --- a/readme.md +++ b/readme.md @@ -166,15 +166,19 @@ InvalidOperationException: Failed to convert configuration value '-5' at 'Retry: ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') ``` -Both guards earn their place, and they catch different things. `ValidateOnStart()` handles the *invalid* value: binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. `ValidateDataAnnotations()` with `[Required]` handles the *missing* one, which `ValidateOnStart()` alone will not: an absent key raises nothing, because binding succeeds and simply doesn't assign. +`ValidateOnStart()` handles the *invalid* value: binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. -Three edges worth knowing, all inherited from `ConfigurationBinder` rather than introduced here: +It does **not** handle the *missing* one. An absent key raises nothing, because binding succeeds and simply doesn't assign — so an unconfigured `NonEmptyString Name` is `null` (a wrapper's invariant constrains every value it can hold; it can't make the binder assign one) and an unconfigured `Positive` is `1`. `[Required]` finds the first but never the second, because `1` isn't null. For that, add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/): -- **A wrapper doesn't survive an unconfigured key.** Its invariant constrains every value it can hold, but can't make the binder assign one — so an unconfigured `NonEmptyString Name` is `null`, exactly as a `string` would be. Use `[Required]`. -- **A non-nullable struct wrapper can't be checked for absence at all.** `default(Positive)` is `1` — a real, invariant-satisfying value — so `[Required]` passes and nothing tells "configured as 1" from "never configured". Declare it `Positive?` when that matters. -- **`null` and `""` are not interchangeable.** An explicit `"Name": null` nulls even a non-nullable `NonEmptyString`, so omit the key rather than writing `null`; and `""` throws for every wrapper *except* a nullable struct one, where the BCL's `NullableConverter` maps it to `null` first. +```csharp +builder.Services.AddOptions() + .BindStrongTypes(builder.Configuration.GetSection("Retry")) // requires every non-nullable wrapper + .ValidateOnStart(); +``` + +It asks configuration whether the key is present rather than asking the bound object whether it looks null, and takes required-ness from the declaration — `Positive` required, `Positive?` optional. Analyzer `ST0004` flags a plain `Bind` that needs it. -The full matrix is in the [skill reference](Skill/references/configuration.md). +One more edge, inherited from `ConfigurationBinder`: **`null` and `""` are not interchangeable.** An explicit `"Name": null` nulls even a non-nullable `NonEmptyString`, so omit the key rather than writing `null`; and `""` throws for every wrapper *except* a nullable struct one, where the BCL's `NullableConverter` maps it to `null` first. The full matrix is in the [skill reference](Skill/references/configuration.md). [↑ Back to contents](#contents) @@ -796,6 +800,7 @@ Result[], string> ParseOrderQuantities(IEnumerable inputs) | Package | Purpose | Readme | | --- | --- | --- | | [`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) | Core types: `NonEmptyString`, `Positive` / `NonNegative` / `Negative` / `NonPositive`, `NonEmptyEnumerable`, `Maybe`, `Result`, plus `System.Text.Json` converters. | (this readme) | +| [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a non-nullable strong-type property has no configuration key. Binding itself needs no package; this is only about the key that isn't there. | [readme](src/StrongTypes.Configuration/readme.md) | | [`Kalicz.StrongTypes.EfCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore/) | EF Core value converters + `DbContext` extension for round-tripping the wrappers through scalar columns. | [readme](src/StrongTypes.EfCore/readme.md) | | [`Kalicz.StrongTypes.FsCheck`](https://www.nuget.org/packages/Kalicz.StrongTypes.FsCheck/) | FsCheck `Arbitrary` generators for property-based (generative) testing of code that takes or returns the wrappers. | [readme](src/StrongTypes.FsCheck/readme.md) | | [`Kalicz.StrongTypes.OpenApi.Microsoft`](https://www.nuget.org/packages/Kalicz.StrongTypes.OpenApi.Microsoft/) | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so the generated document matches the wire JSON. | [readme](src/StrongTypes.OpenApi.Microsoft/readme.md) | diff --git a/src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs b/src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs new file mode 100644 index 00000000..45c80481 --- /dev/null +++ b/src/StrongTypes.Analyzers.Tests/Infrastructure/DocumentCodeFixTester.cs @@ -0,0 +1,82 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace StrongTypes.Analyzers.Tests.Infrastructure; + +/// +/// Drives a code fix that edits source, as opposed to , which exists for +/// the package-install fixes that write to a csproj and so cannot be observed as a document change. +/// Diagnostics come from really running the analyzer, so a fix cannot be tested against a location +/// the analyzer would never report. +/// +internal static class DocumentCodeFixTester +{ + public static async Task> RegisterFixesAsync( + DiagnosticAnalyzer analyzer, + CodeFixProvider provider, + string source, + IEnumerable references) + { + var (document, diagnostics) = await AnalyseAsync(analyzer, source, references); + if (diagnostics.IsEmpty) + { + return []; + } + + var registered = new List(); + var context = new CodeFixContext(document, diagnostics[0], (action, _) => registered.Add(action), CancellationToken.None); + await provider.RegisterCodeFixesAsync(context); + return registered; + } + + /// Applies the first offered fix and returns the resulting source. + public static async Task ApplySingleFixAsync( + DiagnosticAnalyzer analyzer, + CodeFixProvider provider, + string source, + IEnumerable references) + { + var (document, diagnostics) = await AnalyseAsync(analyzer, source, references); + if (diagnostics.IsEmpty) + { + throw new InvalidOperationException("The analyzer reported nothing, so there is no fix to apply."); + } + + var registered = new List(); + var context = new CodeFixContext(document, diagnostics[0], (action, _) => registered.Add(action), CancellationToken.None); + await provider.RegisterCodeFixesAsync(context); + + var operations = await registered.Single().GetOperationsAsync(CancellationToken.None); + var changed = operations.OfType().Single().ChangedSolution; + var text = await changed.GetDocument(document.Id)!.GetTextAsync(); + return text.ToString(); + } + + private static async Task<(Document Document, ImmutableArray Diagnostics)> AnalyseAsync( + DiagnosticAnalyzer analyzer, + string source, + IEnumerable references) + { + var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var documentId = DocumentId.CreateNewId(projectId); + + var solution = workspace.CurrentSolution + .AddProject(projectId, "Target", "Target", LanguageNames.CSharp) + .WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReferences(projectId, references) + .AddDocument(documentId, "Test.cs", source); + + var document = solution.GetDocument(documentId)!; + var compilation = await document.Project.GetCompilationAsync(); + var diagnostics = await compilation! + .WithAnalyzers(ImmutableArray.Create(analyzer)) + .GetAnalyzerDiagnosticsAsync(); + + return (document, diagnostics); + } +} diff --git a/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs b/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs index d2aefb42..a2432323 100644 --- a/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs +++ b/src/StrongTypes.Analyzers.Tests/Infrastructure/TestReferences.cs @@ -47,6 +47,19 @@ internal static class TestReferences public static readonly MetadataReference StrongTypesOpenApiSwashbuckle = MetadataReference.CreateFromFile(typeof(global::StrongTypes.OpenApi.Swashbuckle.StrongTypesSwashbuckleExtensions).Assembly.Location); + public static readonly MetadataReference StrongTypesConfiguration = + MetadataReference.CreateFromFile(typeof(global::StrongTypes.Configuration.OptionsBuilderExtensions).Assembly.Location); + + /// What an options-binding source needs to compile: IServiceCollection, OptionsBuilder<T>, IConfiguration, the Bind/Configure extensions ST0004 matches on, and [Required]. + public static readonly MetadataReference[] OptionsStack = + [ + MetadataReference.CreateFromFile(typeof(global::Microsoft.Extensions.DependencyInjection.IServiceCollection).Assembly.Location), + MetadataReference.CreateFromFile(typeof(global::Microsoft.Extensions.Options.IOptions).Assembly.Location), + MetadataReference.CreateFromFile(typeof(global::Microsoft.Extensions.Configuration.IConfiguration).Assembly.Location), + MetadataReference.CreateFromFile(typeof(global::Microsoft.Extensions.DependencyInjection.OptionsBuilderConfigurationExtensions).Assembly.Location), + MetadataReference.CreateFromFile(typeof(global::System.ComponentModel.DataAnnotations.RequiredAttribute).Assembly.Location), + ]; + private static IReadOnlyList BuildCoreReferences() { // AppContext "TRUSTED_PLATFORM_ASSEMBLIES" isn't always populated under diff --git a/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj b/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj index 4dc5317a..09a8d51f 100644 --- a/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj +++ b/src/StrongTypes.Analyzers.Tests/StrongTypes.Analyzers.Tests.csproj @@ -15,6 +15,8 @@ + + @@ -26,6 +28,7 @@ OutputItemType="None" /> + diff --git a/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs b/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs new file mode 100644 index 00000000..7f810cc1 --- /dev/null +++ b/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs @@ -0,0 +1,178 @@ +using Microsoft.CodeAnalysis; +using StrongTypes.Analyzers.Tests.Infrastructure; +using Xunit; + +namespace StrongTypes.Analyzers.Tests; + +/// +/// Behaviour tests for (ST0004). Each source +/// opts into nullable reference types, because required-ness for a reference wrapper is read from +/// the annotation and the test compilation does not enable it project-wide. +/// +public class UnvalidatedStrongTypeOptionsAnalyzerTests +{ + private const string Header = """ + #nullable enable + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using System.ComponentModel.DataAnnotations; + using StrongTypes; + + namespace Sample; + + """; + + private static string Source(string options, string registration) => $$""" + {{Header}} + public class RetryOptions + { + {{options}} + } + + public static class Startup + { + public static void Register(IServiceCollection services, IConfiguration config) + { + {{registration}} + } + } + """; + + private const string BindRegistration = " services.AddOptions().Bind(config.GetSection(\"Retry\"));"; + private const string ConfigureRegistration = " services.Configure(config.GetSection(\"Retry\"));"; + + private static Task> RunAsync(string source) => + AnalyzerTester.RunAsync(new UnvalidatedStrongTypeOptionsAnalyzer(), source, TestReferences.With(TestReferences.OptionsStack)); + + // ── Fires ─────────────────────────────────────────────────────────── + + /// The case nothing else catches: default(Positive<int>) is 1, so [Required] would pass even if it were there. + [Theory] + [InlineData(BindRegistration)] + [InlineData(ConfigureRegistration)] + public async Task Fires_for_a_non_nullable_struct_wrapper(string registration) + { + var diagnostics = await RunAsync(Source(" public Positive MaxRetries { get; set; }", registration)); + + var diagnostic = Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + Assert.Contains("RetryOptions", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("MaxRetries", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + /// A struct wrapper's default is never null, so [Required] cannot rescue it and the diagnostic still fires. + [Fact] + public async Task Fires_for_a_non_nullable_struct_wrapper_even_with_Required() + { + var diagnostics = await RunAsync(Source( + " [Required] public Positive MaxRetries { get; set; }", + BindRegistration)); + + Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } + + [Fact] + public async Task Fires_for_a_non_nullable_reference_wrapper_without_Required() + { + var diagnostics = await RunAsync(Source( + " public NonEmptyString Name { get; set; } = null!;", + BindRegistration)); + + var diagnostic = Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + Assert.Contains("Name", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public async Task Reports_every_unguarded_property_in_one_diagnostic() + { + var diagnostics = await RunAsync(Source(""" + public NonEmptyString Name { get; set; } = null!; + public Positive MaxRetries { get; set; } + """, BindRegistration)); + + var diagnostic = Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + Assert.Contains("Name", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("MaxRetries", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + // ── Silent ────────────────────────────────────────────────────────── + + /// [Required] does find a null reference wrapper, so nagging there would be wrong. + [Fact] + public async Task Silent_for_a_reference_wrapper_carrying_Required() + { + var diagnostics = await RunAsync(Source( + " [Required] public NonEmptyString Name { get; set; } = null!;", + BindRegistration)); + + Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } + + [Theory] + [InlineData(" public Positive? MaxRetries { get; set; }")] + [InlineData(" public NonEmptyString? Name { get; set; }")] + public async Task Silent_when_every_wrapper_is_nullable(string options) + { + var diagnostics = await RunAsync(Source(options, BindRegistration)); + + Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } + + [Fact] + public async Task Silent_when_the_options_type_holds_no_strong_types() + { + var diagnostics = await RunAsync(Source(""" + public string Name { get; set; } = ""; + public int MaxRetries { get; set; } + """, BindRegistration)); + + Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } + + /// A read-only property is not something the binder assigns, so its absence is not a configuration problem. + [Fact] + public async Task Silent_for_a_get_only_property() + { + var diagnostics = await RunAsync(Source( + " public Positive MaxRetries { get; } = Positive.Create(3);", + BindRegistration)); + + Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } + + /// Nothing to say when the project never binds options — the analyzer must no-op rather than fire on the declaration alone. + [Fact] + public async Task Silent_when_the_options_type_is_never_bound() + { + var diagnostics = await RunAsync($$""" + {{Header}} + public class RetryOptions + { + public Positive MaxRetries { get; set; } + } + """); + + Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } + + /// Without the Options stack referenced there is no Bind to match, and the analyzer must stay silent rather than throw. + [Fact] + public async Task Silent_when_options_is_not_referenced() + { + var diagnostics = await AnalyzerTester.RunAsync( + new UnvalidatedStrongTypeOptionsAnalyzer(), + """ + #nullable enable + using StrongTypes; + + namespace Sample; + + public class RetryOptions + { + public Positive MaxRetries { get; set; } + } + """, + TestReferences.With()); + + Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } +} diff --git a/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs b/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs new file mode 100644 index 00000000..caed5446 --- /dev/null +++ b/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs @@ -0,0 +1,86 @@ +using StrongTypes.Analyzers.Tests.Infrastructure; +using Xunit; + +namespace StrongTypes.Analyzers.Tests; + +public class UseBindStrongTypesCodeFixProviderTests +{ + private const string BindSource = """ + #nullable enable + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using StrongTypes; + + namespace Sample; + + public class RetryOptions + { + public Positive MaxRetries { get; set; } + } + + public static class Startup + { + public static void Register(IServiceCollection services, IConfiguration config) + { + services.AddOptions().Bind(config.GetSection("Retry")); + } + } + """; + + private static IEnumerable WithConfiguration() => + TestReferences.With([.. TestReferences.OptionsStack, TestReferences.StrongTypesConfiguration]); + + [Fact] + public async Task Rewrites_Bind_to_BindStrongTypes() + { + var fixedSource = await DocumentCodeFixTester.ApplySingleFixAsync( + new UnvalidatedStrongTypeOptionsAnalyzer(), + new UseBindStrongTypesCodeFixProvider(), + BindSource, + WithConfiguration()); + + Assert.Contains("services.AddOptions().BindStrongTypes(config.GetSection(\"Retry\"));", fixedSource, StringComparison.Ordinal); + Assert.DoesNotContain(".Bind(config", fixedSource, StringComparison.Ordinal); + } + + [Fact] + public async Task Adds_the_configuration_using() + { + var fixedSource = await DocumentCodeFixTester.ApplySingleFixAsync( + new UnvalidatedStrongTypeOptionsAnalyzer(), + new UseBindStrongTypesCodeFixProvider(), + BindSource, + WithConfiguration()); + + Assert.Contains("using StrongTypes.Configuration;", fixedSource, StringComparison.Ordinal); + } + + /// Without the package the rewrite would not compile, so no fix is offered — the diagnostic's help link points at the package instead. + [Fact] + public async Task Not_offered_when_the_configuration_package_is_absent() + { + var actions = await DocumentCodeFixTester.RegisterFixesAsync( + new UnvalidatedStrongTypeOptionsAnalyzer(), + new UseBindStrongTypesCodeFixProvider(), + BindSource, + TestReferences.With(TestReferences.OptionsStack)); + + Assert.Empty(actions); + } + + /// Rewriting Configure<T> means restructuring to AddOptions<T>().BindStrongTypes(…); the diagnostic still fires, but the change is the caller's to make. + [Fact] + public async Task Not_offered_for_Configure() + { + var actions = await DocumentCodeFixTester.RegisterFixesAsync( + new UnvalidatedStrongTypeOptionsAnalyzer(), + new UseBindStrongTypesCodeFixProvider(), + BindSource.Replace( + "services.AddOptions().Bind(config.GetSection(\"Retry\"));", + "services.Configure(config.GetSection(\"Retry\"));", + StringComparison.Ordinal), + WithConfiguration()); + + Assert.Empty(actions); + } +} diff --git a/src/StrongTypes.Analyzers/AnalyzerReleases.Unshipped.md b/src/StrongTypes.Analyzers/AnalyzerReleases.Unshipped.md index f2b7fad6..c9ff46a1 100644 --- a/src/StrongTypes.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/StrongTypes.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +ST0004 | Usage | Warning | Bind strong-typed options with BindStrongTypes (Kalicz.StrongTypes.Configuration) so a missing configuration key fails diff --git a/src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs b/src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs new file mode 100644 index 00000000..a8e5993c --- /dev/null +++ b/src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace StrongTypes.Analyzers; + +/// +/// Fires when an options type carrying a strong type that must be configured is bound with +/// Bind / Configure, which cannot notice the key is missing. +/// +/// +/// A wrapper's invariant constrains every value it can hold; it cannot make the binder assign one. +/// An unconfigured NonEmptyString is therefore null and an unconfigured +/// Positive<int> is 1 — its default, an ordinary invariant-satisfying value. +/// ValidateOnStart() does not help: binding an absent key succeeds without assigning, so +/// nothing is raised. +/// +/// Only reported where [Required] would not already cover it — a struct wrapper, whose +/// default is never null and so always passes [Required], or a reference wrapper with no +/// [Required] at all. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnvalidatedStrongTypeOptionsAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "ST0004"; + public const string ConfigurationPackageId = "Kalicz.StrongTypes.Configuration"; + + private static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticId, + title: "Bind strong-typed options with BindStrongTypes", + messageFormat: "Options type '{0}' requires configuration for {1}; bind with BindStrongTypes() so a missing key fails instead of silently defaulting", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Binding an absent configuration key succeeds without assigning, so a non-nullable strong type keeps a default: null for a reference wrapper, and for a struct wrapper an ordinary value ([Required] cannot see that default(Positive) is 1 rather than a configured 1). Kalicz.StrongTypes.Configuration's BindStrongTypes() checks the section for each required key instead, taking required-ness from the declaration — Positive is required, Positive? is optional.", + helpLinkUri: "https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration"); + + private static readonly ImmutableHashSet StrongTypeMetadataNames = ImmutableHashSet.Create( + "StrongTypes.NonEmptyString", + "StrongTypes.Email", + "StrongTypes.Digit", + "StrongTypes.Positive`1", + "StrongTypes.NonNegative`1", + "StrongTypes.Negative`1", + "StrongTypes.NonPositive`1"); + + // The two shapes that bind a whole options type from a section and have a + // BindStrongTypes equivalent. ConfigurationBinder.Get/Bind(object) are + // deliberately excluded: they are one-off reads with no OptionsBuilder to fix. + private const string OptionsBuilderBindExtensions = "Microsoft.Extensions.DependencyInjection.OptionsBuilderConfigurationExtensions"; + private const string ServiceCollectionConfigureExtensions = "Microsoft.Extensions.DependencyInjection.OptionsConfigurationServiceCollectionExtensions"; + + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(compilationStart => + { + var compilation = compilationStart.Compilation; + + var bindExtensions = compilation.GetTypeByMetadataName(OptionsBuilderBindExtensions); + var configureExtensions = compilation.GetTypeByMetadataName(ServiceCollectionConfigureExtensions); + if (bindExtensions is null && configureExtensions is null) + { + return; + } + + var requiredAttribute = compilation.GetTypeByMetadataName("System.ComponentModel.DataAnnotations.RequiredAttribute"); + + compilationStart.RegisterOperationAction(operationContext => + { + var invocation = (IInvocationOperation)operationContext.Operation; + var method = invocation.TargetMethod; + + if (!IsBindingCall(method, bindExtensions, configureExtensions)) + { + return; + } + if (method.TypeArguments.FirstOrDefault() is not INamedTypeSymbol optionsType) + { + return; + } + + var unguarded = CollectPropertiesNeedingConfiguration(optionsType, requiredAttribute); + if (unguarded.Count == 0) + { + return; + } + + operationContext.ReportDiagnostic(Diagnostic.Create( + Rule, + invocation.Syntax.GetLocation(), + optionsType.Name, + string.Join(", ", unguarded.Select(p => p.Name)))); + }, OperationKind.Invocation); + }); + } + + private static bool IsBindingCall(IMethodSymbol method, INamedTypeSymbol? bindExtensions, INamedTypeSymbol? configureExtensions) + { + if (method.TypeArguments.Length != 1) + { + return false; + } + var containing = method.ContainingType; + if (method.Name == "Bind" && bindExtensions is not null && SymbolEqualityComparer.Default.Equals(containing, bindExtensions)) + { + return true; + } + return method.Name == "Configure" + && configureExtensions is not null + && SymbolEqualityComparer.Default.Equals(containing, configureExtensions); + } + + /// Properties whose absence nothing would notice: a struct wrapper (whose default satisfies [Required]) or an unannotated-as-nullable reference wrapper without [Required]. + private static List CollectPropertiesNeedingConfiguration(INamedTypeSymbol optionsType, INamedTypeSymbol? requiredAttribute) + { + var result = new List(); + for (var current = optionsType; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers()) + { + if (member is not IPropertySymbol property || property.SetMethod is null || property.IsIndexer) + { + continue; + } + if (!IsStrongType(property.Type) || IsOptional(property)) + { + continue; + } + // A struct wrapper's default is never null, so [Required] always passes on it. + if (!property.Type.IsValueType && HasRequiredAttribute(property, requiredAttribute)) + { + continue; + } + result.Add(property); + } + } + return result; + } + + private static bool HasRequiredAttribute(IPropertySymbol property, INamedTypeSymbol? requiredAttribute) => + requiredAttribute is not null + && property.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, requiredAttribute)); + + /// An assembly compiled without nullable reference types annotates nothing, so a reference wrapper reads as — no intent declared, nothing to enforce. + private static bool IsOptional(IPropertySymbol property) + { + if (property.Type is INamedTypeSymbol named + && named.IsGenericType + && named.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T) + { + return true; + } + if (property.Type.IsValueType) + { + return false; + } + return property.NullableAnnotation != NullableAnnotation.NotAnnotated; + } + + private static bool IsStrongType(ITypeSymbol type) + { + if (type is INamedTypeSymbol nullable + && nullable.IsGenericType + && nullable.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T) + { + type = nullable.TypeArguments[0]; + } + if (type is not INamedTypeSymbol named) + { + return false; + } + var definition = named.OriginalDefinition; + var ns = definition.ContainingNamespace?.ToDisplayString(); + var metadataName = definition.IsGenericType + ? $"{ns}.{definition.Name}`{definition.TypeParameters.Length}" + : $"{ns}.{definition.Name}"; + return StrongTypeMetadataNames.Contains(metadataName); + } +} diff --git a/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs b/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs new file mode 100644 index 00000000..3f815a87 --- /dev/null +++ b/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace StrongTypes.Analyzers; + +/// +/// Code fix for : rewrites +/// .Bind(section) to .BindStrongTypes(section). +/// +/// +/// Offered only when Kalicz.StrongTypes.Configuration is already referenced — without it the +/// rewrite would not compile. A services.Configure<T>(section) call still reports the +/// diagnostic but gets no fix: rewriting it means restructuring to +/// AddOptions<T>().BindStrongTypes(section), which is the caller's call to make. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseBindStrongTypesCodeFixProvider))] +[Shared] +public sealed class UseBindStrongTypesCodeFixProvider : CodeFixProvider +{ + private const string BindStrongTypes = "BindStrongTypes"; + private const string ConfigurationNamespace = "StrongTypes.Configuration"; + + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId); + + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var compilation = await context.Document.Project.GetCompilationAsync(context.CancellationToken).ConfigureAwait(false); + if (compilation is null || !ReferencesConfigurationPackage(compilation)) + { + return; + } + + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var invocation = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true) + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (invocation?.Expression is not MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Bind" } access) + { + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: "Use BindStrongTypes()", + createChangedDocument: ct => UseBindStrongTypesAsync(context.Document, access, ct), + equivalenceKey: nameof(UseBindStrongTypesCodeFixProvider)), + diagnostic); + } + } + + private static bool ReferencesConfigurationPackage(Compilation compilation) => + compilation.ReferencedAssemblyNames.Any(a => + string.Equals(a.Name, "StrongTypes.Configuration", StringComparison.OrdinalIgnoreCase) + || string.Equals(a.Name, UnvalidatedStrongTypeOptionsAnalyzer.ConfigurationPackageId, StringComparison.OrdinalIgnoreCase)); + + private static async Task UseBindStrongTypesAsync( + Document document, + MemberAccessExpressionSyntax access, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var renamed = access.WithName(SyntaxFactory.IdentifierName(BindStrongTypes).WithTriviaFrom(access.Name)); + var updated = root.ReplaceNode(access, renamed); + + return document.WithSyntaxRoot(AddUsing(updated)); + } + + private static SyntaxNode AddUsing(SyntaxNode root) + { + if (root is not CompilationUnitSyntax unit) + { + return root; + } + if (unit.Usings.Any(u => u.Name?.ToString() == ConfigurationNamespace)) + { + return unit; + } + + var directive = SyntaxFactory + .UsingDirective(SyntaxFactory.ParseName(ConfigurationNamespace)) + .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed); + + // Keep the file's usings sorted rather than appending to the end. + var insertAt = unit.Usings.TakeWhile(u => string.CompareOrdinal(u.Name?.ToString(), ConfigurationNamespace) < 0).Count(); + return unit.WithUsings(unit.Usings.Insert(insertAt, directive)); + } +} From 54941f5aaddf11542967f4c389e98539faf6e73f Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 08:51:19 +0200 Subject: [PATCH 09/24] Require every property BindStrongTypes can read intent for, not just wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling BindStrongTypes is the intent: this options class should be fully configured. A missing string is exactly as silent as a missing NonEmptyString, so policing only our wrappers made the guarantee arbitrary. A property is required when it is not nullable and the options class gives it no default of its own — read by probing a fresh instance, which separates a real fallback from the `= null!` that only appeases nullable reference types. Without that, `public string Endpoint { get; set; } = "https://x.test"` would fail despite plainly stating its default, and the feature would be unusable. It also fixes the same latent bug for wrappers. Presence now asks Exists() rather than Value != null. A collection or nested object is a section with children and no value of its own, so the old check called a configured `Items: ["a","b"]` unconfigured — invisible while only scalar wrappers were policed, a false failure the moment anything else is. Two declarations cannot be read, and are documented rather than guessed: a value type whose intended default is the CLR default (`bool Enabled = false`) is indistinguishable from having no initialiser, so it is required — declare it nullable; and a reference property in an assembly without nullable reference types carries no annotation, so it stays optional. Computing the required set per type in a static field also drops the lock NullabilityInfoContext needed, since it now runs once on one thread. Co-Authored-By: Claude Opus 4.8 --- Skill/SKILL.md | 2 +- Skill/references/configuration.md | 25 +++- readme.md | 2 +- .../BindStrongTypesTests.cs | 103 ++++++++++++-- .../OptionsBuilderExtensions.cs | 39 ++++-- .../RequiredConfigurationKeysValidator.cs | 126 ++++++++++++++++++ .../RequiredStrongTypeKeysValidator.cs | 60 --------- .../StrongTypeProperty.cs | 47 ------- .../StrongTypes.Configuration.csproj | 2 +- src/StrongTypes.Configuration/readme.md | 45 ++++--- 10 files changed, 299 insertions(+), 152 deletions(-) create mode 100644 src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs delete mode 100644 src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs delete mode 100644 src/StrongTypes.Configuration/StrongTypeProperty.cs diff --git a/Skill/SKILL.md b/Skill/SKILL.md index bae59ace..ca931000 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -20,7 +20,7 @@ demand when about to write code against that surface. | Package | What it gives you | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `Kalicz.StrongTypes` | The core types (`NonEmptyString`, numeric wrappers, `NonEmptyEnumerable`, `Maybe`, `Result`, …). | -| `Kalicz.StrongTypes.Configuration` | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a non-nullable strong-type property has no configuration key, which `[Required]` cannot do for struct wrappers (`default(Positive)` is `1`, never null). | +| `Kalicz.StrongTypes.Configuration` | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when any property that expects a value (not nullable, no default of its own) has no configuration key. Catches what `[Required]` cannot: `default(Positive)` is `1`, never null. | | `Kalicz.StrongTypes.EfCore` | EF Core value converters, interval column mapping (two endpoint columns by default, JSON opt-in), and LINQ translators (`.Unwrap()`, interval `Start`/`End`) so strong types sit directly on entity properties. | | `Kalicz.StrongTypes.FsCheck` | FsCheck `Arbitrary` generators registered via `[Properties(Arbitrary = new[] { typeof(Generators) })]`. | | `Kalicz.StrongTypes.OpenApi.Microsoft` | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so wrappers render as the wire JSON shape, not the CLR shape. | diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 3a9202e8..7f7f8771 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -81,11 +81,26 @@ OptionsValidationException: 'Retry:MaxRetries' is required but was not configure Declare RetryOptions.MaxRetries nullable if it is optional. ``` -`Positive` is required, `Positive?` is optional. It works where `[Required]` cannot -because it asks **configuration** whether the key is present, rather than asking the bound object -whether it looks null — and `default(Positive)` is `1`, which looks like a configured value. -Only Kalicz.StrongTypes wrappers are checked; `string`/`int` properties are left to whatever -validation you already have. +It works where `[Required]` cannot because it asks **configuration** whether the key is present, +rather than asking the bound object whether it looks null — and `default(Positive)` is `1`, +which looks like a configured value. + +A property is **required when it is not nullable and the options class gives it no default**: + +```csharp +public NonEmptyString Name { get; set; } = null!; // required — null! declares no default +public NonEmptyString? Nickname { get; set; } // optional — nullable +public Positive MaxRetries { get; set; } // required +public string Endpoint { get; set; } = "https://x.test"; // optional — has a default +public int? Timeout { get; set; } // optional +``` + +**Every property is checked, not only the wrappers** — opting in says the class should be fully +configured, and a missing `string` is as silent as a missing `NonEmptyString`. Two declarations +cannot be read: a value type whose intended default *is* the CLR default +(`bool Enabled { get; set; } = false`) is required, since it is indistinguishable from having no +initialiser — declare it `bool?`; and a reference property in an assembly with +`disable` carries no annotation and is treated as optional. Analyzer **ST0004** flags a plain `Bind` / `Configure` on an options type that needs this, with a code fix that rewrites the call. It stays quiet for a reference wrapper already carrying diff --git a/readme.md b/readme.md index 85adf140..235af513 100644 --- a/readme.md +++ b/readme.md @@ -800,7 +800,7 @@ Result[], string> ParseOrderQuantities(IEnumerable inputs) | Package | Purpose | Readme | | --- | --- | --- | | [`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) | Core types: `NonEmptyString`, `Positive` / `NonNegative` / `Negative` / `NonPositive`, `NonEmptyEnumerable`, `Maybe`, `Result`, plus `System.Text.Json` converters. | (this readme) | -| [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a non-nullable strong-type property has no configuration key. Binding itself needs no package; this is only about the key that isn't there. | [readme](src/StrongTypes.Configuration/readme.md) | +| [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when any property that expects a value (not nullable, no default of its own) has no configuration key. Binding itself needs no package; this is only about the key that isn't there. | [readme](src/StrongTypes.Configuration/readme.md) | | [`Kalicz.StrongTypes.EfCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore/) | EF Core value converters + `DbContext` extension for round-tripping the wrappers through scalar columns. | [readme](src/StrongTypes.EfCore/readme.md) | | [`Kalicz.StrongTypes.FsCheck`](https://www.nuget.org/packages/Kalicz.StrongTypes.FsCheck/) | FsCheck `Arbitrary` generators for property-based (generative) testing of code that takes or returns the wrappers. | [readme](src/StrongTypes.FsCheck/readme.md) | | [`Kalicz.StrongTypes.OpenApi.Microsoft`](https://www.nuget.org/packages/Kalicz.StrongTypes.OpenApi.Microsoft/) | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so the generated document matches the wire JSON. | [readme](src/StrongTypes.OpenApi.Microsoft/readme.md) | diff --git a/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs index d1bd73cc..7adfd055 100644 --- a/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs +++ b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs @@ -22,8 +22,24 @@ private sealed class RetryOptions public Positive MaxRetries { get; set; } public Positive? Score { get; set; } public Digit Tier { get; set; } + } + + private sealed class MixedOptions + { + public NonEmptyString Name { get; set; } = null!; public string PlainString { get; set; } = null!; + public string? OptionalString { get; set; } public int PlainInt { get; set; } + public int? OptionalInt { get; set; } + public string WithDefault { get; set; } = "https://example.test"; + public Positive Retries { get; set; } = Positive.Create(3); + public List Items { get; set; } = []; + public NestedOptions Nested { get; set; } = new(); + } + + private sealed class NestedOptions + { + public string Inner { get; set; } = ""; } private const string FullyConfigured = """ @@ -88,15 +104,17 @@ public void EveryMissingKey_IsReportedTogether() Assert.Contains("Retry:MaxRetries", failures, StringComparison.Ordinal); } - /// The failure has to name the config path, not the property — that is what the reader has to go and edit. + /// The failure has to name the config path, not just the property — that is what the reader goes and edits — and both ways out of it. [Fact] - public void Failure_NamesTheConfigurationPathAndTheFix() + public void Failure_NamesTheConfigurationPathAndBothFixes() { var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout", "Tier": 7 } }"""); var failure = Assert.Single(exception.Failures); Assert.Contains("Retry:MaxRetries", failure, StringComparison.Ordinal); - Assert.Contains("nullable if it is optional", failure, StringComparison.Ordinal); + Assert.Contains("RetryOptions.MaxRetries", failure, StringComparison.Ordinal); + Assert.Contains("a default", failure, StringComparison.Ordinal); + Assert.Contains("declare it nullable", failure, StringComparison.Ordinal); } // ── What it must not do ───────────────────────────────────────────── @@ -120,14 +138,83 @@ public void MissingNullableWrappers_DoNotFail() Assert.Null(options.Score); } - /// Only our wrappers are policed; an unconfigured string or int is left to whatever validation the caller already has. + // ── Every property type, not only the wrappers ────────────────────── + // + // Opting in says the options class should be fully configured: an unconfigured + // string is exactly as silent as an unconfigured NonEmptyString. + [Fact] - public void MissingPlainProperties_AreNotPoliced() + public void MissingPlainProperties_AreRequiredToo() { - var options = Bind(FullyConfigured); + var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout" } }"""); + var failures = string.Join(" | ", exception.Failures); - Assert.Null(options.PlainString); - Assert.Equal(0, options.PlainInt); + Assert.Contains("Retry:PlainString", failures, StringComparison.Ordinal); + Assert.Contains("Retry:PlainInt", failures, StringComparison.Ordinal); + } + + [Fact] + public void NullablePlainProperties_AreOptional() + { + var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout" } }"""); + var failures = string.Join(" | ", exception.Failures); + + Assert.DoesNotContain("OptionalString", failures, StringComparison.Ordinal); + Assert.DoesNotContain("OptionalInt", failures, StringComparison.Ordinal); + } + + /// A property the options class initialises has a stated fallback, so configuration is not required to supply one. + [Fact] + public void PropertiesWithADeclaredDefault_AreOptional() + { + var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout" } }"""); + var failures = string.Join(" | ", exception.Failures); + + Assert.DoesNotContain("WithDefault", failures, StringComparison.Ordinal); + Assert.DoesNotContain("Retries", failures, StringComparison.Ordinal); + } + + /// = null! appeases nullable reference types without declaring a fallback, so the property is still required. + [Fact] + public void NullBangInitialiser_IsNotADeclaredDefault() + { + var exception = BindExpectingFailure("""{ "Retry": { "PlainInt": 1 } }"""); + + Assert.Contains("Retry:Name", string.Join(" | ", exception.Failures), StringComparison.Ordinal); + } + + /// + /// A collection or nested object is a section with children and no value of its own, so a + /// presence check on the value alone would call these unconfigured. They are both initialised + /// here, so neither is required — and configuring them must not fail either. + /// + [Fact] + public void ConfiguredCollectionsAndNestedObjects_AreSeenAsConfigured() + { + var options = Bind(""" + { + "Retry": { + "Name": "checkout", "PlainString": "s", "PlainInt": 1, + "Items": [ "a", "b" ], + "Nested": { "Inner": "y" } + } + } + """); + + Assert.Equal(["a", "b"], options.Items); + Assert.Equal("y", options.Nested.Inner); + } + + [Fact] + public void MixedOptions_FullyConfigured_Binds() + { + var options = Bind("""{ "Retry": { "Name": "checkout", "PlainString": "s", "PlainInt": 42 } }"""); + + Assert.Equal("checkout", options.Name.Value); + Assert.Equal("s", options.PlainString); + Assert.Equal(42, options.PlainInt); + Assert.Equal("https://example.test", options.WithDefault); + Assert.Equal(3, options.Retries.Value); } /// Presence is the only question asked here; an invalid value is still the converter's failure, thrown while binding. diff --git a/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs b/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs index 3789f7ae..e4c4780c 100644 --- a/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs +++ b/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs @@ -6,22 +6,41 @@ namespace StrongTypes.Configuration; public static class OptionsBuilderExtensions { - /// Binds to and requires a configuration key for every non-nullable strong-type property on it. + /// Binds to and requires a configuration key for every property that declares it expects a value. /// The options builder to bind. /// The configuration section to bind from. /// , for chaining — pair with ValidateOnStart() to fail the host rather than the first request that reads the options. /// /// - /// A wrapper's invariant constrains every value it can hold; it cannot make the binder assign - /// one. An unconfigured NonEmptyString is therefore null, and an unconfigured - /// Positive<int> is 1 — its default, an ordinary value that no [Required] - /// can distinguish from a configured one. This checks the section for the key instead of - /// checking the bound object for a null, so both cases fail. + /// A property is required when it is not nullable and the options class gives it no default of + /// its own. Everything else is optional: /// + /// + /// public NonEmptyString Name { get; set; } = null!; // required — null! declares no default + /// public NonEmptyString? Nickname { get; set; } // optional — nullable + /// public Positive<int> MaxRetries { get; set; } // required + /// public Positive<int>? Score { get; set; } // optional — nullable + /// public string Endpoint { get; set; } = "https://x.test"; // optional — has a default + /// /// - /// The declaration is the spec: Positive<int> is required, Positive<int>? - /// is optional. Only Kalicz.StrongTypes wrappers are checked. A property in an assembly compiled - /// without nullable reference types carries no annotation and is treated as optional. + /// Every property type is checked, not only Kalicz.StrongTypes wrappers: opting in says the + /// options class should be fully configured, and a missing string is as silent as a + /// missing NonEmptyString. + /// + /// + /// This is about the key that is absent. A wrapper's invariant constrains every value + /// it can hold; it cannot make the binder assign one, so an unconfigured NonEmptyString + /// is null and an unconfigured Positive<int> is 1 — its default, which + /// no [Required] can tell from a configured 1. This checks the section for each + /// key instead of checking the bound object for a null, so both cases fail. A key that is + /// present but invalid still fails while binding, with the invariant's own message. + /// + /// + /// Two declarations cannot be read and are treated as required: a value type whose intended + /// default is the CLR default (bool Enabled { get; set; } = false), and — for the + /// nullable half of the rule — a reference property in an assembly compiled without nullable + /// reference types, which carries no annotation and is instead treated as optional. Declare a + /// property nullable when it is optional and neither applies. /// /// /// or is null. @@ -33,7 +52,7 @@ public static OptionsBuilder BindStrongTypes(this OptionsBui builder.Bind(section); builder.Services.AddSingleton>( - new RequiredStrongTypeKeysValidator(builder.Name, section)); + new RequiredConfigurationKeysValidator(builder.Name, section)); return builder; } diff --git a/src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs b/src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs new file mode 100644 index 00000000..c97ba827 --- /dev/null +++ b/src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs @@ -0,0 +1,126 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace StrongTypes.Configuration; + +/// Fails validation when a property of that must be configured has no key in the bound section. +/// The options type being bound. +internal sealed class RequiredConfigurationKeysValidator(string? name, IConfiguration section) : IValidateOptions + where TOptions : class +{ + // Per-type and immutable, so this runs once in the static constructor — + // which also keeps NullabilityInfoContext, which is not thread-safe, on one thread. + private static readonly PropertyInfo[] RequiredProperties = FindPropertiesRequiringConfiguration(); + + public ValidateOptionsResult Validate(string? optionsName, TOptions options) + { + // A named registration validates only its own name; an unnamed one covers all. + if (name is not null && name != optionsName) + { + return ValidateOptionsResult.Skip; + } + + List? failures = null; + + foreach (var property in RequiredProperties) + { + // Exists() rather than a null Value: a collection or nested object is a section + // with children and no value of its own, and is configured all the same. + if (section.GetSection(property.Name).Exists()) + { + continue; + } + + failures ??= []; + failures.Add( + $"'{Path(property.Name)}' is required but was not configured. Give {typeof(TOptions).Name}.{property.Name} " + + $"a default, or declare it nullable, if it is optional."); + } + + return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + private string Path(string propertyName) => + section is IConfigurationSection { Path.Length: > 0 } s ? $"{s.Path}:{propertyName}" : propertyName; + + /// + /// A property must be configured when its declaration says a value is expected — it is not + /// nullable — and the options class supplies none of its own. + /// + private static PropertyInfo[] FindPropertiesRequiringConfiguration() + { + var declaredDefaults = TryConstructProbe(); + var nullability = new NullabilityInfoContext(); + + var required = new List(); + foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetSetMethod() is null || property.GetIndexParameters().Length > 0) + { + continue; + } + if (IsOptional(property, nullability) || SuppliesItsOwnDefault(property, declaredDefaults)) + { + continue; + } + required.Add(property); + } + return [.. required]; + } + + /// A fresh instance, to read what the options class declares before configuration touches it. + private static TOptions? TryConstructProbe() + { + try + { + return Activator.CreateInstance(); + } + catch (Exception e) when (e is MissingMethodException or MemberAccessException or TargetInvocationException) + { + // Binding needs a parameterless constructor anyway, so this is unreachable in practice + // and would already have failed. Treating every property as having no default is the + // strict reading, and the failure it produces names the key either way. + return null; + } + } + + /// + /// True when the options class initialises the property to anything other than the CLR default, + /// which is the only way it can say "optional, and here is the fallback". + /// + /// + /// A value type whose intended default is the CLR default — bool Enabled { get; set; } = false + /// — is indistinguishable from one with no initialiser at all, and so is required. Declare it + /// nullable to make it optional. + /// + private static bool SuppliesItsOwnDefault(PropertyInfo property, TOptions? declaredDefaults) + { + if (declaredDefaults is null) + { + return false; + } + + var declared = property.GetValue(declaredDefaults); + var clrDefault = property.PropertyType.IsValueType ? Activator.CreateInstance(property.PropertyType) : null; + return !Equals(declared, clrDefault); + } + + /// + /// A reference property in an assembly compiled without nullable reference types carries no + /// annotation, so it reads as and is treated as optional: + /// with no intent declared there is nothing to enforce. + /// + private static bool IsOptional(PropertyInfo property, NullabilityInfoContext nullability) + { + if (Nullable.GetUnderlyingType(property.PropertyType) is not null) + { + return true; + } + if (property.PropertyType.IsValueType) + { + return false; + } + return nullability.Create(property).WriteState != NullabilityState.NotNull; + } +} diff --git a/src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs b/src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs deleted file mode 100644 index 83e8ec67..00000000 --- a/src/StrongTypes.Configuration/RequiredStrongTypeKeysValidator.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Reflection; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; - -namespace StrongTypes.Configuration; - -/// Fails validation when a non-nullable strong-type property on has no key in the bound section. -/// The options type being bound. -internal sealed class RequiredStrongTypeKeysValidator(string? name, IConfiguration section) : IValidateOptions - where TOptions : class -{ - private static readonly NullabilityInfoContext NullabilityContext = new(); - private static readonly Lock NullabilityGate = new(); - - public ValidateOptionsResult Validate(string? optionsName, TOptions options) - { - // A named registration validates only its own name; Configure(null) covers all. - if (name is not null && name != optionsName) - { - return ValidateOptionsResult.Skip; - } - - List? failures = null; - - foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (property.GetSetMethod() is null || property.GetIndexParameters().Length > 0) - { - continue; - } - if (!StrongTypeProperty.IsStrongType(property.PropertyType) || StrongTypeProperty.IsOptional(property, Nullability)) - { - continue; - } - if (section.GetSection(property.Name).Value is not null) - { - continue; - } - - failures ??= []; - failures.Add($"'{Path(property.Name)}' is required but was not configured. Declare {typeof(TOptions).Name}.{property.Name} nullable if it is optional."); - } - - return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); - } - - private string Path(string propertyName) => - string.IsNullOrEmpty(section is IConfigurationSection s ? s.Path : null) - ? propertyName - : $"{((IConfigurationSection)section).Path}:{propertyName}"; - - /// is documented as not thread-safe, and options validation can run concurrently. - private static NullabilityInfo Nullability(PropertyInfo property) - { - lock (NullabilityGate) - { - return NullabilityContext.Create(property); - } - } -} diff --git a/src/StrongTypes.Configuration/StrongTypeProperty.cs b/src/StrongTypes.Configuration/StrongTypeProperty.cs deleted file mode 100644 index 2b3e3bdb..00000000 --- a/src/StrongTypes.Configuration/StrongTypeProperty.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Reflection; - -namespace StrongTypes.Configuration; - -internal static class StrongTypeProperty -{ - private static readonly HashSet StrongTypeDefinitions = - [ - typeof(NonEmptyString), - typeof(Email), - typeof(Digit), - typeof(Positive<>), - typeof(NonNegative<>), - typeof(Negative<>), - typeof(NonPositive<>), - ]; - - public static bool IsStrongType(Type type) - { - var underlying = Nullable.GetUnderlyingType(type) ?? type; - return StrongTypeDefinitions.Contains(underlying.IsGenericType ? underlying.GetGenericTypeDefinition() : underlying); - } - - /// - /// True when the declaration says the value may be absent: Positive<int>?, or a - /// reference wrapper the assembly annotated as nullable. - /// - /// - /// An assembly compiled without nullable reference types carries no annotation, so a reference - /// wrapper reads as . That is treated as optional: with - /// nothing declared there is no intent to enforce, and failing would make the package unusable - /// for those projects. - /// - public static bool IsOptional(PropertyInfo property, Func nullability) - { - if (Nullable.GetUnderlyingType(property.PropertyType) is not null) - { - return true; - } - if (property.PropertyType.IsValueType) - { - return false; - } - - return nullability(property).WriteState != NullabilityState.NotNull; - } -} diff --git a/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj b/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj index bd7843d6..eeab0cd2 100644 --- a/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj +++ b/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj @@ -11,7 +11,7 @@ Kalicz.StrongTypes.Configuration KaliCZ Copyright © 2026 KaliCZ - Configuration binding support for Kalicz.StrongTypes. Adds OptionsBuilder<T>.BindStrongTypes(), which binds a section and then fails when a non-nullable strong-type property has no configuration key — the case [Required] cannot catch for struct wrappers, whose default is an ordinary value rather than null. + Configuration binding support for Kalicz.StrongTypes. Adds OptionsBuilder<T>.BindStrongTypes(), which binds a section and then fails when any property that expects a value — not nullable, and given no default of its own — has no configuration key. Catches what [Required] cannot: default(Positive<int>) is 1, an ordinary value rather than null. StrongTypes, Configuration, Options, IOptions, Validation, NonEmptyString MIT false diff --git a/src/StrongTypes.Configuration/readme.md b/src/StrongTypes.Configuration/readme.md index d30ed0e7..aa3a0e16 100644 --- a/src/StrongTypes.Configuration/readme.md +++ b/src/StrongTypes.Configuration/readme.md @@ -44,31 +44,38 @@ OptionsValidationException: 'Retry:MaxRetries' is required but was not configure Declare RetryOptions.MaxRetries nullable if it is optional. ``` -The declaration is the spec — no attributes: +The declaration is the spec — no attributes. A property is **required when it is not nullable and +the options class gives it no default of its own**: -| declaration | meaning | -| --------------------------- | ----------- | -| `Positive MaxRetries` | required | -| `Positive? MaxRetries` | optional | -| `NonEmptyString Name` | required | -| `NonEmptyString? Nickname` | optional | +```csharp +public NonEmptyString Name { get; set; } = null!; // required — null! declares no default +public NonEmptyString? Nickname { get; set; } // optional — nullable +public Positive MaxRetries { get; set; } // required +public Positive? Score { get; set; } // optional — nullable +public string Endpoint { get; set; } = "https://x.test"; // optional — has a default +public string ApiKey { get; set; } = null!; // required +public int Timeout { get; set; } // required +public int? Timeout { get; set; } // optional +``` -Only Kalicz.StrongTypes wrappers are checked; a `string` or `int` property is left to whatever -validation you already use. +**Every property is checked, not only the wrappers.** Opting in says this options class should be +fully configured, and a missing `string` is exactly as silent as a missing `NonEmptyString`. A +collection or nested object counts as configured when the section has it, value or children. Pair it with `ValidateOnStart()`. On its own the failure is still lazy — it surfaces on the first read of `IOptions.Value`, not at startup. -## When you don't need it +## Two declarations it cannot read -- No strong types in your options classes. -- Every strong-typed property is nullable, so nothing is required. -- You already have an `IValidateOptions` covering presence. +- **A value type whose intended default is the CLR default.** `bool Enabled { get; set; } = false` + is indistinguishable from `bool Enabled { get; set; }`, so it is required. Declare it `bool?` to + make it optional. +- **A reference property in an assembly with `disable`.** It carries no + annotation, so it is treated as **optional** — with no intent declared there is nothing to + enforce. Struct properties are unaffected: `Positive` vs `Positive?` lives in the type + and is always readable. -## Nullable reference types +## When you don't need it -Required-ness for a reference wrapper is read from the assembly's nullable annotations. An options -class in a project with `disable` carries none, so its reference wrappers are -treated as **optional** — with no intent declared there is nothing to enforce. Struct wrappers -(`Positive` vs `Positive?`) are unaffected: that distinction is in the type itself and -always readable. +- Every property on your options classes is nullable or has a default, so nothing is required. +- You already have an `IValidateOptions` covering presence. From 46c2d35a4009e05983ce2db0e7c91f080f2312c7 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 09:10:11 +0200 Subject: [PATCH 10/24] Test the presence check against sections that have children, not a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collection and nested-object test proved nothing: both properties were initialised, so they were optional and the presence check never ran on them. Reverting the check to `Value is not null` left all 17 green. They are now declared `= null!`, which makes them required — and that same revert now fails two tests. Pinning the real behaviour along the way, none of which was guessable: - "Items": [] is configured. The provider records an empty array as an empty value, so the key is present. The binder still leaves the property null rather than building an empty list: presence and non-nullness are different questions and this answers the first. - "Nested": { } is not configured — neither value nor children. - "Name": null is not configured, so a required property rejects it. That closes the hole plain binding leaves, where an explicit null quietly nulls a non-nullable NonEmptyString. Co-Authored-By: Claude Opus 4.8 --- Skill/references/configuration.md | 11 ++- .../BindStrongTypesTests.cs | 82 +++++++++++++++---- src/StrongTypes.Configuration/readme.md | 26 +++++- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 7f7f8771..32843efa 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -96,8 +96,15 @@ public int? Timeout { get; set; } // optional ``` **Every property is checked, not only the wrappers** — opting in says the class should be fully -configured, and a missing `string` is as silent as a missing `NonEmptyString`. Two declarations -cannot be read: a value type whose intended default *is* the CLR default +configured, and a missing `string` is as silent as a missing `NonEmptyString`. It also rejects an +explicit `"Name": null`, which plain binding accepts even on a non-nullable property. + +What counts as configured is whether the **section** holds anything: `"Items": [ "a" ]` and +`"Nested": { "X": 1 }` do (they have children), `"Items": []` does (an empty value, deliberately), +while `"Nested": { }` and `"Name": null` do not. Note `"Items": []` satisfies the requirement but +still binds `null` rather than an empty list — presence and non-nullness are different questions. + +Two declarations cannot be read: a value type whose intended default *is* the CLR default (`bool Enabled { get; set; } = false`) is required, since it is indistinguishable from having no initialiser — declare it `bool?`; and a reference property in an assembly with `disable` carries no annotation and is treated as optional. diff --git a/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs index 7adfd055..f9431c01 100644 --- a/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs +++ b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs @@ -33,8 +33,13 @@ private sealed class MixedOptions public int? OptionalInt { get; set; } public string WithDefault { get; set; } = "https://example.test"; public Positive Retries { get; set; } = Positive.Create(3); - public List Items { get; set; } = []; - public NestedOptions Nested { get; set; } = new(); + } + + /// No initialisers, so both are required and the presence check actually runs on them — which is the only way to exercise a section that has children rather than a value. + private sealed class RequiredCollectionOptions + { + public List Items { get; set; } = null!; + public NestedOptions Nested { get; set; } = null!; } private sealed class NestedOptions @@ -184,27 +189,76 @@ public void NullBangInitialiser_IsNotADeclaredDefault() } /// - /// A collection or nested object is a section with children and no value of its own, so a - /// presence check on the value alone would call these unconfigured. They are both initialised - /// here, so neither is required — and configuring them must not fail either. + /// A configured collection or nested object holds no scalar value of its own: the JSON array + /// flattens to Retry:Items:0 / Retry:Items:1 beneath a valueless Retry:Items + /// parent. Asking whether the section has a Value therefore calls this plainly-configured + /// pair missing; asking whether it Exists() does not. /// [Fact] - public void ConfiguredCollectionsAndNestedObjects_AreSeenAsConfigured() + public void RequiredCollectionAndNestedObject_ConfiguredAsChildren_AreSeenAsConfigured() { - var options = Bind(""" - { - "Retry": { - "Name": "checkout", "PlainString": "s", "PlainInt": 1, - "Items": [ "a", "b" ], - "Nested": { "Inner": "y" } - } - } + var options = Bind(""" + { "Retry": { "Items": [ "a", "b" ], "Nested": { "Inner": "y" } } } """); Assert.Equal(["a", "b"], options.Items); Assert.Equal("y", options.Nested.Inner); } + /// The counterpart: Exists() must not simply wave everything through. + [Fact] + public void RequiredCollectionAndNestedObject_WhenActuallyAbsent_Fail() + { + var exception = BindExpectingFailure("""{ "Retry": { "Unrelated": 1 } }"""); + var failures = string.Join(" | ", exception.Failures); + + Assert.Contains("Retry:Items", failures, StringComparison.Ordinal); + Assert.Contains("Retry:Nested", failures, StringComparison.Ordinal); + } + + /// + /// An empty array is a deliberate configuration — the provider records it as an empty value + /// rather than nothing — so the key is present and the requirement is met. The binder still + /// leaves the property null rather than building an empty list: this validates that the section + /// was configured, which is not the same question as whether the bound value is non-null, and + /// the two only coincide for a scalar. + /// + [Fact] + public void RequiredCollection_ConfiguredEmpty_SatisfiesPresenceButBindsNull() + { + var options = Bind(""" + { "Retry": { "Items": [], "Nested": { "Inner": "y" } } } + """); + + Assert.Null(options.Items); + } + + /// An empty object holds neither value nor children, so it says nothing and reads as absent. + [Fact] + public void RequiredNestedObject_ConfiguredEmpty_ReadsAsAbsent() + { + var exception = BindExpectingFailure(""" + { "Retry": { "Items": [ "a" ], "Nested": {} } } + """); + + Assert.Contains("Retry:Nested", string.Join(" | ", exception.Failures), StringComparison.Ordinal); + } + + /// + /// An explicit null does not satisfy a required property. That also closes the hole plain + /// binding leaves, where "Name": null quietly nulls a non-nullable NonEmptyString + /// because nullability is erased before the binder runs. + /// + [Fact] + public void RequiredProperty_ExplicitNull_Fails() + { + var exception = BindExpectingFailure(""" + { "Retry": { "Name": null, "MaxRetries": 5, "Tier": 7 } } + """); + + Assert.Contains("Retry:Name", string.Join(" | ", exception.Failures), StringComparison.Ordinal); + } + [Fact] public void MixedOptions_FullyConfigured_Binds() { diff --git a/src/StrongTypes.Configuration/readme.md b/src/StrongTypes.Configuration/readme.md index aa3a0e16..59896447 100644 --- a/src/StrongTypes.Configuration/readme.md +++ b/src/StrongTypes.Configuration/readme.md @@ -59,8 +59,30 @@ public int? Timeout { get; set; } // optional ``` **Every property is checked, not only the wrappers.** Opting in says this options class should be -fully configured, and a missing `string` is exactly as silent as a missing `NonEmptyString`. A -collection or nested object counts as configured when the section has it, value or children. +fully configured, and a missing `string` is exactly as silent as a missing `NonEmptyString`. + +It also rejects an explicit `null`, which plain binding does not — `"Name": null` otherwise leaves +a non-nullable `NonEmptyString` holding `null`, because nullability is erased before the binder runs. + +## What counts as configured + +The question is whether the **section** holds anything, not whether the bound value ends up non-null. +For a scalar the two coincide; for a collection or nested object they need care, because those are +sections with *children* and no value of their own — `"Items": [ "a", "b" ]` flattens to +`Retry:Items:0` and `Retry:Items:1` beneath a valueless `Retry:Items`. + +| in `appsettings.json` | configured? | +| --------------------- | ----------- | +| `"Items": [ "a" ]` | yes — has children | +| `"Items": []` | yes — recorded as an empty value, and a deliberate one | +| `"Nested": { "X": 1 }`| yes — has children | +| `"Nested": { }` | no — neither value nor children | +| `"Name": null` | no — the key is there but says nothing | +| key absent | no | + +One wrinkle worth knowing: `"Items": []` satisfies the requirement, but the binder still leaves the +property `null` rather than building an empty list. Presence and non-nullness are different +questions, and this answers the first. Pair it with `ValidateOnStart()`. On its own the failure is still lazy — it surfaces on the first read of `IOptions.Value`, not at startup. From 286ae56a4a58ecfeafaeacb48cd5a561ee42fffb Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 09:18:33 +0200 Subject: [PATCH 11/24] Check for nulls, not for configuration keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The requirement was wrong, and so was the reasoning behind it. Requiring every non-nullable property assumed an unconfigured value type is a problem. It is not: an unconfigured Positive is 1 and an unconfigured bool is false — defaults, and values those types are perfectly happy to hold. Only null in something whose declaration says it can never be null is a contradiction, and only a reference can reach it. So "the case [Required] cannot catch" was never a case. Nothing needed catching there, and requiring configuration for a struct was a policy invented to justify the mechanism rather than a fix for anything. Asking the bound object for nulls instead of asking configuration for keys drops all of it: the Activator probe for declared defaults (a default is simply not null), the Exists() handling for sections that have children rather than a value (a bound collection is simply not null), and the empty-array puzzle it created. The validator is now half the size and answers the question that matters. ST0004 narrows to match: a struct wrapper has nothing to guard, so reporting it was noise. It now fires only for a non-nullable reference wrapper with no [Required]. Co-Authored-By: Claude Opus 4.8 --- Skill/SKILL.md | 4 +- Skill/references/configuration.md | 73 +++-- readme.md | 8 +- ...validatedStrongTypeOptionsAnalyzerTests.cs | 50 ++-- .../UseBindStrongTypesCodeFixProviderTests.cs | 2 +- .../UnvalidatedStrongTypeOptionsAnalyzer.cs | 39 +-- .../BindStrongTypesTests.cs | 255 ++++++------------ .../NonNullableOptionsValidator.cs | 82 ++++++ .../OptionsBuilderExtensions.cs | 41 ++- .../RequiredConfigurationKeysValidator.cs | 126 --------- .../StrongTypes.Configuration.csproj | 2 +- src/StrongTypes.Configuration/readme.md | 100 +++---- 12 files changed, 296 insertions(+), 486 deletions(-) create mode 100644 src/StrongTypes.Configuration/NonNullableOptionsValidator.cs delete mode 100644 src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs diff --git a/Skill/SKILL.md b/Skill/SKILL.md index ca931000..caf509d9 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -20,7 +20,7 @@ demand when about to write code against that surface. | Package | What it gives you | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `Kalicz.StrongTypes` | The core types (`NonEmptyString`, numeric wrappers, `NonEmptyEnumerable`, `Maybe`, `Result`, …). | -| `Kalicz.StrongTypes.Configuration` | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when any property that expects a value (not nullable, no default of its own) has no configuration key. Catches what `[Required]` cannot: `default(Positive)` is `1`, never null. | +| `Kalicz.StrongTypes.Configuration` | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a property declared non-nullable is null once bound, which an absent key otherwise leaves silently. Reads intent from nullable annotations, so no `[Required]` per property. | | `Kalicz.StrongTypes.EfCore` | EF Core value converters, interval column mapping (two endpoint columns by default, JSON opt-in), and LINQ translators (`.Unwrap()`, interval `Start`/`End`) so strong types sit directly on entity properties. | | `Kalicz.StrongTypes.FsCheck` | FsCheck `Arbitrary` generators registered via `[Properties(Arbitrary = new[] { typeof(Generators) })]`. | | `Kalicz.StrongTypes.OpenApi.Microsoft` | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so wrappers render as the wire JSON shape, not the CLR shape. | @@ -29,7 +29,7 @@ demand when about to write code against that surface. Add packages only when the host project actually hits that stack: -- **Configuration** — when a strong type sits on an options class and must be configured. Binding works without it (every wrapper carries a `TypeConverter`); the package is only about the key that isn't there. Skip it if every strong-typed option is nullable. See `references/configuration.md`. +- **Configuration** — when a non-nullable reference property (`NonEmptyString`, `Email`, or a plain `string`) sits on an options class. Binding works without it (every wrapper carries a `TypeConverter`); the package only stops an absent key leaving that property null. Skip it if every reference property is nullable or has a default, or if you already use `[Required]`. See `references/configuration.md`. - **EfCore** — only if EF Core is in use. - **FsCheck** — only for property-based test projects. - **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines. diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 32843efa..559c7288 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -81,33 +81,27 @@ OptionsValidationException: 'Retry:MaxRetries' is required but was not configure Declare RetryOptions.MaxRetries nullable if it is optional. ``` -It works where `[Required]` cannot because it asks **configuration** whether the key is present, -rather than asking the bound object whether it looks null — and `default(Positive)` is `1`, -which looks like a configured value. - -A property is **required when it is not nullable and the options class gives it no default**: +It fails when a property **declared non-nullable is null after binding** — the one state an absent +key can leave that the declaration forbids. Your nullable annotations already say which properties +those are, so no `[Required]` has to repeat it: ```csharp -public NonEmptyString Name { get; set; } = null!; // required — null! declares no default -public NonEmptyString? Nickname { get; set; } // optional — nullable -public Positive MaxRetries { get; set; } // required -public string Endpoint { get; set; } = "https://x.test"; // optional — has a default -public int? Timeout { get; set; } // optional +public NonEmptyString Name { get; set; } = null!; // fails unless configured +public NonEmptyString? Nickname { get; set; } // nullable — fine +public string ApiKey { get; set; } = null!; // fails unless configured +public string Endpoint { get; set; } = "https://x.test"; // has a default — never null ``` -**Every property is checked, not only the wrappers** — opting in says the class should be fully -configured, and a missing `string` is as silent as a missing `NonEmptyString`. It also rejects an -explicit `"Name": null`, which plain binding accepts even on a non-nullable property. +**Every reference property is covered, not only the wrappers** — a non-nullable `string` is as +broken by a missing key as a `NonEmptyString`. A declared default needs nothing: it isn't null. -What counts as configured is whether the **section** holds anything: `"Items": [ "a" ]` and -`"Nested": { "X": 1 }` do (they have children), `"Items": []` does (an empty value, deliberately), -while `"Nested": { }` and `"Name": null` do not. Note `"Items": []` satisfies the requirement but -still binds `null` rather than an empty list — presence and non-nullness are different questions. +**Value types are not checked, and don't need to be.** An unconfigured `Positive` is `1` and an +unconfigured `bool` is `false` — defaults, and values those types are happy to hold. There is no +contradiction to catch, so this will not tell you that you forgot `MaxRetries`. Declare it +`Positive?` if "not configured" must be distinguishable; that is the only way the CLR offers. -Two declarations cannot be read: a value type whose intended default *is* the CLR default -(`bool Enabled { get; set; } = false`) is required, since it is indistinguishable from having no -initialiser — declare it `bool?`; and a reference property in an assembly with -`disable` carries no annotation and is treated as optional. +An options class in an assembly with `disable` declares no intent, so nothing +on it is enforced. Analyzer **ST0004** flags a plain `Bind` / `Configure` on an options type that needs this, with a code fix that rewrites the call. It stays quiet for a reference wrapper already carrying @@ -122,33 +116,32 @@ there raises nothing — binding succeeds, it just doesn't assign — so the property keeps whatever the options class gave it. Since a real options class has no initialisers, that means: -| declaration | key not in config | `[Required]` catches it? | -| ------------------------------- | ----------------- | ------------------------ | -| `NonEmptyString Name` | **`null`** | yes | -| `Positive MaxRetries` | **`1`** (default) | **no** | -| `Positive? MaxRetries` | `null` | yes | - -Two consequences worth internalising: +| declaration | key not in config | is that a problem? | +| --------------------------- | ----------------- | ----------------------------------------- | +| `NonEmptyString Name` | **`null`** | **yes** — the type says it is never null | +| `Positive MaxRetries` | `1` (default) | no — a value the type is happy to hold | +| `Positive? MaxRetries` | `null` | no — it is nullable | -- **A non-nullable `NonEmptyString` can be null.** The invariant constrains - every value the type can hold; it cannot make the binder assign one. The - wrapper is no better than `string` at surviving an unconfigured key. -- **A non-nullable struct wrapper cannot be checked at all.** - `default(Positive)` is `1` — a real, invariant-satisfying value — so - `[Required]` passes and nothing distinguishes "configured as 1" from "never - configured". **Declare it `Positive?` when that distinction matters.** +**A non-nullable `NonEmptyString` can be null.** The invariant constrains every +value the type can hold; it cannot make the binder assign one. Against an +unconfigured key the wrapper is no better than `string`, and that is the only +place an absent key produces a state the declaration forbids. -So the full guard is both: +Guard it with `[Required]` and `ValidateDataAnnotations()` — or with +`BindStrongTypes()` above, which reads the same intent from the nullable +annotation instead of an attribute: ```csharp builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Retry")) - .ValidateDataAnnotations() // [Required] → catches a missing value - .ValidateOnStart(); // → catches an invalid one, at startup + .ValidateDataAnnotations() // [Required] → catches a null reference property + .ValidateOnStart(); // → catches an invalid value, at startup ``` -…with `[Required]` on each property that must be present, and struct wrappers -declared nullable so `[Required]` has a null to find. +Neither can help with a struct: `default(Positive)` is `1`, so nothing +distinguishes "configured as 1" from "never configured". Declare it +`Positive?` when that distinction matters — the CLR offers no other way +to say it. ## `null` and `""` — the exact matrix diff --git a/readme.md b/readme.md index 235af513..dadeb2af 100644 --- a/readme.md +++ b/readme.md @@ -168,15 +168,15 @@ InvalidOperationException: Failed to convert configuration value '-5' at 'Retry: `ValidateOnStart()` handles the *invalid* value: binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. -It does **not** handle the *missing* one. An absent key raises nothing, because binding succeeds and simply doesn't assign — so an unconfigured `NonEmptyString Name` is `null` (a wrapper's invariant constrains every value it can hold; it can't make the binder assign one) and an unconfigured `Positive` is `1`. `[Required]` finds the first but never the second, because `1` isn't null. For that, add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/): +It does **not** handle the *missing* one. An absent key raises nothing, because binding succeeds and simply doesn't assign — so an unconfigured `NonEmptyString Name` is `null`, in the one type whose whole purpose is never being null. (A wrapper's invariant constrains every value it can hold; it can't make the binder assign one.) `[Required]` covers it, an attribute at a time. Or add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/), which reads the same intent from the nullable annotations you already wrote: ```csharp builder.Services.AddOptions() - .BindStrongTypes(builder.Configuration.GetSection("Retry")) // requires every non-nullable wrapper + .BindStrongTypes(builder.Configuration.GetSection("Retry")) // fails on a null non-nullable property .ValidateOnStart(); ``` -It asks configuration whether the key is present rather than asking the bound object whether it looks null, and takes required-ness from the declaration — `Positive` required, `Positive?` optional. Analyzer `ST0004` flags a plain `Bind` that needs it. +Value types aren't checked and don't need to be: an unconfigured `Positive` is `1` — a default, and a value the type is happy to hold, not a contradiction. Analyzer `ST0004` flags a plain `Bind` that would leave a wrapper null. One more edge, inherited from `ConfigurationBinder`: **`null` and `""` are not interchangeable.** An explicit `"Name": null` nulls even a non-nullable `NonEmptyString`, so omit the key rather than writing `null`; and `""` throws for every wrapper *except* a nullable struct one, where the BCL's `NullableConverter` maps it to `null` first. The full matrix is in the [skill reference](Skill/references/configuration.md). @@ -800,7 +800,7 @@ Result[], string> ParseOrderQuantities(IEnumerable inputs) | Package | Purpose | Readme | | --- | --- | --- | | [`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) | Core types: `NonEmptyString`, `Positive` / `NonNegative` / `Negative` / `NonPositive`, `NonEmptyEnumerable`, `Maybe`, `Result`, plus `System.Text.Json` converters. | (this readme) | -| [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when any property that expects a value (not nullable, no default of its own) has no configuration key. Binding itself needs no package; this is only about the key that isn't there. | [readme](src/StrongTypes.Configuration/readme.md) | +| [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a property declared non-nullable is null once bound, which an absent key otherwise leaves silently. Binding itself needs no package; this is only about the key that isn't there. | [readme](src/StrongTypes.Configuration/readme.md) | | [`Kalicz.StrongTypes.EfCore`](https://www.nuget.org/packages/Kalicz.StrongTypes.EfCore/) | EF Core value converters + `DbContext` extension for round-tripping the wrappers through scalar columns. | [readme](src/StrongTypes.EfCore/readme.md) | | [`Kalicz.StrongTypes.FsCheck`](https://www.nuget.org/packages/Kalicz.StrongTypes.FsCheck/) | FsCheck `Arbitrary` generators for property-based (generative) testing of code that takes or returns the wrappers. | [readme](src/StrongTypes.FsCheck/readme.md) | | [`Kalicz.StrongTypes.OpenApi.Microsoft`](https://www.nuget.org/packages/Kalicz.StrongTypes.OpenApi.Microsoft/) | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so the generated document matches the wire JSON. | [readme](src/StrongTypes.OpenApi.Microsoft/readme.md) | diff --git a/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs b/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs index 7f810cc1..cb57c22f 100644 --- a/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs +++ b/src/StrongTypes.Analyzers.Tests/UnvalidatedStrongTypeOptionsAnalyzerTests.cs @@ -46,38 +46,16 @@ public static void Register(IServiceCollection services, IConfiguration config) // ── Fires ─────────────────────────────────────────────────────────── - /// The case nothing else catches: default(Positive<int>) is 1, so [Required] would pass even if it were there. + /// A non-nullable reference wrapper is the one an absent key leaves null — the state its type forbids. [Theory] [InlineData(BindRegistration)] [InlineData(ConfigureRegistration)] - public async Task Fires_for_a_non_nullable_struct_wrapper(string registration) + public async Task Fires_for_a_non_nullable_reference_wrapper(string registration) { - var diagnostics = await RunAsync(Source(" public Positive MaxRetries { get; set; }", registration)); + var diagnostics = await RunAsync(Source(" public NonEmptyString Name { get; set; } = null!;", registration)); var diagnostic = Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); Assert.Contains("RetryOptions", diagnostic.GetMessage(), StringComparison.Ordinal); - Assert.Contains("MaxRetries", diagnostic.GetMessage(), StringComparison.Ordinal); - } - - /// A struct wrapper's default is never null, so [Required] cannot rescue it and the diagnostic still fires. - [Fact] - public async Task Fires_for_a_non_nullable_struct_wrapper_even_with_Required() - { - var diagnostics = await RunAsync(Source( - " [Required] public Positive MaxRetries { get; set; }", - BindRegistration)); - - Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); - } - - [Fact] - public async Task Fires_for_a_non_nullable_reference_wrapper_without_Required() - { - var diagnostics = await RunAsync(Source( - " public NonEmptyString Name { get; set; } = null!;", - BindRegistration)); - - var diagnostic = Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); Assert.Contains("Name", diagnostic.GetMessage(), StringComparison.Ordinal); } @@ -86,12 +64,12 @@ public async Task Reports_every_unguarded_property_in_one_diagnostic() { var diagnostics = await RunAsync(Source(""" public NonEmptyString Name { get; set; } = null!; - public Positive MaxRetries { get; set; } + public Email Contact { get; set; } = null!; """, BindRegistration)); var diagnostic = Assert.Single(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); Assert.Contains("Name", diagnostic.GetMessage(), StringComparison.Ordinal); - Assert.Contains("MaxRetries", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("Contact", diagnostic.GetMessage(), StringComparison.Ordinal); } // ── Silent ────────────────────────────────────────────────────────── @@ -107,10 +85,24 @@ public async Task Silent_for_a_reference_wrapper_carrying_Required() Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); } + [Fact] + public async Task Silent_when_every_wrapper_is_nullable() + { + var diagnostics = await RunAsync(Source(" public NonEmptyString? Name { get; set; }", BindRegistration)); + + Assert.Empty(diagnostics.WhereId(UnvalidatedStrongTypeOptionsAnalyzer.DiagnosticId)); + } + + /// + /// A struct wrapper cannot be null, so an absent key leaves it at a default the type is happy to + /// hold — Positive<int> at 1. There is no contradiction to report, and requiring + /// configuration for it would be a policy rather than a fix. + /// [Theory] + [InlineData(" public Positive MaxRetries { get; set; }")] [InlineData(" public Positive? MaxRetries { get; set; }")] - [InlineData(" public NonEmptyString? Name { get; set; }")] - public async Task Silent_when_every_wrapper_is_nullable(string options) + [InlineData(" public Digit Tier { get; set; }")] + public async Task Silent_for_struct_wrappers(string options) { var diagnostics = await RunAsync(Source(options, BindRegistration)); diff --git a/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs b/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs index caed5446..23c5a518 100644 --- a/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs +++ b/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs @@ -15,7 +15,7 @@ namespace Sample; public class RetryOptions { - public Positive MaxRetries { get; set; } + public NonEmptyString Name { get; set; } = null!; } public static class Startup diff --git a/src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs b/src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs index a8e5993c..2171c3b7 100644 --- a/src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs +++ b/src/StrongTypes.Analyzers/UnvalidatedStrongTypeOptionsAnalyzer.cs @@ -8,19 +8,22 @@ namespace StrongTypes.Analyzers; /// -/// Fires when an options type carrying a strong type that must be configured is bound with -/// Bind / Configure, which cannot notice the key is missing. +/// Fires when an options type carrying a non-nullable reference wrapper is bound with Bind / +/// Configure, which cannot notice the key is missing and leaves the property null. /// /// -/// A wrapper's invariant constrains every value it can hold; it cannot make the binder assign one. -/// An unconfigured NonEmptyString is therefore null and an unconfigured -/// Positive<int> is 1 — its default, an ordinary invariant-satisfying value. -/// ValidateOnStart() does not help: binding an absent key succeeds without assigning, so -/// nothing is raised. +/// A wrapper's invariant constrains every value it can hold; it cannot make the binder assign one, +/// and the binder assigns nothing for an absent key — so an unconfigured NonEmptyString is +/// null, which is what the type says it can never be. ValidateOnStart() does not help: +/// binding an absent key succeeds, so nothing is raised. /// -/// Only reported where [Required] would not already cover it — a struct wrapper, whose -/// default is never null and so always passes [Required], or a reference wrapper with no -/// [Required] at all. +/// Struct wrappers are not reported. An unconfigured Positive<int> is 1 — its +/// default, and a value the type is happy to hold — so there is no contradiction to catch, and +/// requiring configuration for it would be a policy rather than a fix. +/// +/// +/// A property already carrying [Required] is not reported either: that genuinely covers a +/// null reference. /// /// [DiagnosticAnalyzer(LanguageNames.CSharp)] @@ -32,11 +35,11 @@ public sealed class UnvalidatedStrongTypeOptionsAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor Rule = new( id: DiagnosticId, title: "Bind strong-typed options with BindStrongTypes", - messageFormat: "Options type '{0}' requires configuration for {1}; bind with BindStrongTypes() so a missing key fails instead of silently defaulting", + messageFormat: "Options type '{0}' will bind {1} to null when the key is missing; bind with BindStrongTypes() so that fails instead", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "Binding an absent configuration key succeeds without assigning, so a non-nullable strong type keeps a default: null for a reference wrapper, and for a struct wrapper an ordinary value ([Required] cannot see that default(Positive) is 1 rather than a configured 1). Kalicz.StrongTypes.Configuration's BindStrongTypes() checks the section for each required key instead, taking required-ness from the declaration — Positive is required, Positive? is optional.", + description: "Binding an absent configuration key succeeds without assigning, so a non-nullable reference wrapper is left null — the one thing its type says it can never be — and nothing reports it. Kalicz.StrongTypes.Configuration's BindStrongTypes() fails on it instead, reading which properties are non-nullable from the declaration rather than from [Required] attributes. Struct wrappers are unaffected: an unconfigured Positive is 1, a value the type is happy to hold.", helpLinkUri: "https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration"); private static readonly ImmutableHashSet StrongTypeMetadataNames = ImmutableHashSet.Create( @@ -88,7 +91,7 @@ public override void Initialize(AnalysisContext context) return; } - var unguarded = CollectPropertiesNeedingConfiguration(optionsType, requiredAttribute); + var unguarded = CollectPropertiesLeftNull(optionsType, requiredAttribute); if (unguarded.Count == 0) { return; @@ -119,8 +122,8 @@ private static bool IsBindingCall(IMethodSymbol method, INamedTypeSymbol? bindEx && SymbolEqualityComparer.Default.Equals(containing, configureExtensions); } - /// Properties whose absence nothing would notice: a struct wrapper (whose default satisfies [Required]) or an unannotated-as-nullable reference wrapper without [Required]. - private static List CollectPropertiesNeedingConfiguration(INamedTypeSymbol optionsType, INamedTypeSymbol? requiredAttribute) + /// Non-nullable reference wrappers with nothing guarding them — the only properties an absent key can leave in a state their type forbids. + private static List CollectPropertiesLeftNull(INamedTypeSymbol optionsType, INamedTypeSymbol? requiredAttribute) { var result = new List(); for (var current = optionsType; current is not null; current = current.BaseType) @@ -131,12 +134,12 @@ private static List CollectPropertiesNeedingConfiguration(IName { continue; } - if (!IsStrongType(property.Type) || IsOptional(property)) + // A value type has no invalid state to reach, so an absent key leaves nothing wrong. + if (property.Type.IsValueType || !IsStrongType(property.Type)) { continue; } - // A struct wrapper's default is never null, so [Required] always passes on it. - if (!property.Type.IsValueType && HasRequiredAttribute(property, requiredAttribute)) + if (IsOptional(property) || HasRequiredAttribute(property, requiredAttribute)) { continue; } diff --git a/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs index f9431c01..a318871c 100644 --- a/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs +++ b/src/StrongTypes.Configuration.Tests/BindStrongTypesTests.cs @@ -1,4 +1,3 @@ -using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -8,10 +7,9 @@ namespace StrongTypes.Configuration.Tests; /// -/// BindStrongTypes exists for the key that isn't there. Binding an absent key succeeds -/// without assigning, so nothing is raised and the property keeps a default — null for a -/// reference wrapper, and for a struct wrapper an ordinary value that no [Required] can tell -/// from a configured one. +/// The binder assigns nothing for an absent key, so a property it never reaches keeps what the +/// options class gave it. For a reference that is null — which a non-nullable declaration +/// says is impossible — and nothing else notices, because binding itself succeeded. /// public class BindStrongTypesTests { @@ -19,27 +17,23 @@ private sealed class RetryOptions { public NonEmptyString Name { get; set; } = null!; public NonEmptyString? Nickname { get; set; } - public Positive MaxRetries { get; set; } - public Positive? Score { get; set; } - public Digit Tier { get; set; } - } - - private sealed class MixedOptions - { - public NonEmptyString Name { get; set; } = null!; + public Email Contact { get; set; } = null!; public string PlainString { get; set; } = null!; public string? OptionalString { get; set; } - public int PlainInt { get; set; } - public int? OptionalInt { get; set; } public string WithDefault { get; set; } = "https://example.test"; - public Positive Retries { get; set; } = Positive.Create(3); + public NestedOptions Nested { get; set; } = null!; + public List Items { get; set; } = null!; } - /// No initialisers, so both are required and the presence check actually runs on them — which is the only way to exercise a section that has children rather than a value. - private sealed class RequiredCollectionOptions + /// Nothing here can be null, so nothing here is checked. + private sealed class ValueTypeOptions { - public List Items { get; set; } = null!; - public NestedOptions Nested { get; set; } = null!; + public Positive MaxRetries { get; set; } + public NonNegative Delay { get; set; } + public Digit Tier { get; set; } + public bool Enabled { get; set; } + public int Timeout { get; set; } + public Positive? Score { get; set; } } private sealed class NestedOptions @@ -48,7 +42,15 @@ private sealed class NestedOptions } private const string FullyConfigured = """ - { "Retry": { "Name": "checkout", "MaxRetries": 5, "Tier": 7 } } + { + "Retry": { + "Name": "checkout", + "Contact": "ops@example.com", + "PlainString": "s", + "Nested": { "Inner": "y" }, + "Items": [ "a", "b" ] + } + } """; private static IConfigurationSection Section(string json) => @@ -67,57 +69,60 @@ private static TOptions Bind(string json) where TOptions : class private static OptionsValidationException BindExpectingFailure(string json) where TOptions : class => Assert.Throws(() => Bind(json)); + private static string Failures(OptionsValidationException exception) => string.Join(" | ", exception.Failures); + // ── The gap it closes ─────────────────────────────────────────────── - /// The contrast, and the whole reason the package exists: plain Bind takes the same config without a murmur, leaving a null NonEmptyString and a MaxRetries of 1 that reads as deliberate. + /// The contrast, and the whole reason the package exists: plain Bind takes the same config without a murmur and hands back a NonEmptyString that is null. [Fact] - public void PlainBind_AcceptsTheSameMissingKeysSilently() + public void PlainBind_LeavesANonNullableWrapperNull() { var services = new ServiceCollection(); - services.AddOptions().Bind(Section("""{ "Retry": { "Tier": 7 } }""")); + services.AddOptions().Bind(Section("""{ "Retry": { } }""")); var options = services.BuildServiceProvider().GetRequiredService>().Value; Assert.Null(options.Name); - Assert.Equal(1, options.MaxRetries.Value); } - /// The case that motivated the package: default(Positive<int>) is 1, so nothing about the bound object says "never configured". [Fact] - public void MissingNonNullableStructWrapper_Fails() + public void MissingNonNullableWrapper_Fails() { - var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout", "Tier": 7 } }"""); + var exception = BindExpectingFailure("""{ "Retry": { } }"""); - Assert.Contains("'Retry:MaxRetries' is required but was not configured", string.Join(" | ", exception.Failures), StringComparison.Ordinal); + Assert.Contains("'Retry:Name' is null", Failures(exception), StringComparison.Ordinal); + Assert.Contains("'Retry:Contact' is null", Failures(exception), StringComparison.Ordinal); } + /// A plain string declared non-nullable is as broken by a missing key as a wrapper is. [Fact] - public void MissingNonNullableReferenceWrapper_Fails() - { - var exception = BindExpectingFailure("""{ "Retry": { "MaxRetries": 5, "Tier": 7 } }"""); - - Assert.Contains("'Retry:Name' is required but was not configured", string.Join(" | ", exception.Failures), StringComparison.Ordinal); - } + public void MissingNonNullablePlainReference_Fails() => + Assert.Contains("'Retry:PlainString' is null", Failures(BindExpectingFailure("""{ "Retry": { } }""")), StringComparison.Ordinal); [Fact] - public void EveryMissingKey_IsReportedTogether() + public void MissingNonNullableNestedObjectAndCollection_Fail() { - var exception = BindExpectingFailure("""{ "Retry": { "Tier": 7 } }"""); + var failures = Failures(BindExpectingFailure("""{ "Retry": { } }""")); - var failures = string.Join(" | ", exception.Failures); - Assert.Contains("Retry:Name", failures, StringComparison.Ordinal); - Assert.Contains("Retry:MaxRetries", failures, StringComparison.Ordinal); + Assert.Contains("'Retry:Nested' is null", failures, StringComparison.Ordinal); + Assert.Contains("'Retry:Items' is null", failures, StringComparison.Ordinal); } - /// The failure has to name the config path, not just the property — that is what the reader goes and edits — and both ways out of it. + /// An explicit null is no better than an absent key, and plain binding accepts it even here. + [Fact] + public void ExplicitNull_Fails() => + Assert.Contains("'Retry:Name' is null", Failures(BindExpectingFailure("""{ "Retry": { "Name": null } }""")), StringComparison.Ordinal); + + /// The failure names the config path — what the reader goes and edits — plus every way out of it. [Fact] - public void Failure_NamesTheConfigurationPathAndBothFixes() + public void Failure_NamesTheConfigurationPathAndEveryWayOut() { - var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout", "Tier": 7 } }"""); + var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout", "Contact": "a@b.test", "PlainString": "s", "Nested": { "Inner": "y" } } }"""); var failure = Assert.Single(exception.Failures); - Assert.Contains("Retry:MaxRetries", failure, StringComparison.Ordinal); - Assert.Contains("RetryOptions.MaxRetries", failure, StringComparison.Ordinal); + Assert.Contains("'Retry:Items' is null", failure, StringComparison.Ordinal); + Assert.Contains("RetryOptions.Items", failure, StringComparison.Ordinal); + Assert.Contains("Configure it", failure, StringComparison.Ordinal); Assert.Contains("a default", failure, StringComparison.Ordinal); Assert.Contains("declare it nullable", failure, StringComparison.Ordinal); } @@ -130,145 +135,41 @@ public void FullyConfigured_Binds() var options = Bind(FullyConfigured); Assert.Equal("checkout", options.Name.Value); - Assert.Equal(5, options.MaxRetries.Value); - Assert.Equal(7, options.Tier.Value); - } - - [Fact] - public void MissingNullableWrappers_DoNotFail() - { - var options = Bind(FullyConfigured); - - Assert.Null(options.Nickname); - Assert.Null(options.Score); - } - - // ── Every property type, not only the wrappers ────────────────────── - // - // Opting in says the options class should be fully configured: an unconfigured - // string is exactly as silent as an unconfigured NonEmptyString. - - [Fact] - public void MissingPlainProperties_AreRequiredToo() - { - var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout" } }"""); - var failures = string.Join(" | ", exception.Failures); - - Assert.Contains("Retry:PlainString", failures, StringComparison.Ordinal); - Assert.Contains("Retry:PlainInt", failures, StringComparison.Ordinal); - } - - [Fact] - public void NullablePlainProperties_AreOptional() - { - var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout" } }"""); - var failures = string.Join(" | ", exception.Failures); - - Assert.DoesNotContain("OptionalString", failures, StringComparison.Ordinal); - Assert.DoesNotContain("OptionalInt", failures, StringComparison.Ordinal); - } - - /// A property the options class initialises has a stated fallback, so configuration is not required to supply one. - [Fact] - public void PropertiesWithADeclaredDefault_AreOptional() - { - var exception = BindExpectingFailure("""{ "Retry": { "Name": "checkout" } }"""); - var failures = string.Join(" | ", exception.Failures); - - Assert.DoesNotContain("WithDefault", failures, StringComparison.Ordinal); - Assert.DoesNotContain("Retries", failures, StringComparison.Ordinal); - } - - /// = null! appeases nullable reference types without declaring a fallback, so the property is still required. - [Fact] - public void NullBangInitialiser_IsNotADeclaredDefault() - { - var exception = BindExpectingFailure("""{ "Retry": { "PlainInt": 1 } }"""); - - Assert.Contains("Retry:Name", string.Join(" | ", exception.Failures), StringComparison.Ordinal); - } - - /// - /// A configured collection or nested object holds no scalar value of its own: the JSON array - /// flattens to Retry:Items:0 / Retry:Items:1 beneath a valueless Retry:Items - /// parent. Asking whether the section has a Value therefore calls this plainly-configured - /// pair missing; asking whether it Exists() does not. - /// - [Fact] - public void RequiredCollectionAndNestedObject_ConfiguredAsChildren_AreSeenAsConfigured() - { - var options = Bind(""" - { "Retry": { "Items": [ "a", "b" ], "Nested": { "Inner": "y" } } } - """); - + Assert.Equal("ops@example.com", options.Contact.Address); Assert.Equal(["a", "b"], options.Items); Assert.Equal("y", options.Nested.Inner); } - /// The counterpart: Exists() must not simply wave everything through. - [Fact] - public void RequiredCollectionAndNestedObject_WhenActuallyAbsent_Fail() - { - var exception = BindExpectingFailure("""{ "Retry": { "Unrelated": 1 } }"""); - var failures = string.Join(" | ", exception.Failures); - - Assert.Contains("Retry:Items", failures, StringComparison.Ordinal); - Assert.Contains("Retry:Nested", failures, StringComparison.Ordinal); - } - - /// - /// An empty array is a deliberate configuration — the provider records it as an empty value - /// rather than nothing — so the key is present and the requirement is met. The binder still - /// leaves the property null rather than building an empty list: this validates that the section - /// was configured, which is not the same question as whether the bound value is non-null, and - /// the two only coincide for a scalar. - /// [Fact] - public void RequiredCollection_ConfiguredEmpty_SatisfiesPresenceButBindsNull() + public void NullableProperties_AreNotChecked() { - var options = Bind(""" - { "Retry": { "Items": [], "Nested": { "Inner": "y" } } } - """); + var options = Bind(FullyConfigured); - Assert.Null(options.Items); + Assert.Null(options.Nickname); + Assert.Null(options.OptionalString); } - /// An empty object holds neither value nor children, so it says nothing and reads as absent. + /// A declared default is never null, so it needs no check and no configuration. [Fact] - public void RequiredNestedObject_ConfiguredEmpty_ReadsAsAbsent() - { - var exception = BindExpectingFailure(""" - { "Retry": { "Items": [ "a" ], "Nested": {} } } - """); - - Assert.Contains("Retry:Nested", string.Join(" | ", exception.Failures), StringComparison.Ordinal); - } + public void PropertiesWithADeclaredDefault_NeedNoConfiguration() => + Assert.Equal("https://example.test", Bind(FullyConfigured).WithDefault); /// - /// An explicit null does not satisfy a required property. That also closes the hole plain - /// binding leaves, where "Name": null quietly nulls a non-nullable NonEmptyString - /// because nullability is erased before the binder runs. + /// A value type has no invalid state to reach: unconfigured, Positive<int> is its + /// default of 1 and bool is false — values those types are happy to hold. There is + /// nothing here for a null check to find, so none of these is required. /// [Fact] - public void RequiredProperty_ExplicitNull_Fails() - { - var exception = BindExpectingFailure(""" - { "Retry": { "Name": null, "MaxRetries": 5, "Tier": 7 } } - """); - - Assert.Contains("Retry:Name", string.Join(" | ", exception.Failures), StringComparison.Ordinal); - } - - [Fact] - public void MixedOptions_FullyConfigured_Binds() + public void ValueTypeProperties_AreNeverRequired() { - var options = Bind("""{ "Retry": { "Name": "checkout", "PlainString": "s", "PlainInt": 42 } }"""); + var options = Bind("""{ "Retry": { } }"""); - Assert.Equal("checkout", options.Name.Value); - Assert.Equal("s", options.PlainString); - Assert.Equal(42, options.PlainInt); - Assert.Equal("https://example.test", options.WithDefault); - Assert.Equal(3, options.Retries.Value); + Assert.Equal(1, options.MaxRetries.Value); + Assert.Equal(0, options.Delay.Value); + Assert.Equal(0, options.Tier.Value); + Assert.False(options.Enabled); + Assert.Equal(0, options.Timeout); + Assert.Null(options.Score); } /// Presence is the only question asked here; an invalid value is still the converter's failure, thrown while binding. @@ -276,7 +177,7 @@ public void MixedOptions_FullyConfigured_Binds() public void PresentButInvalidValue_StillThrowsTheInvariantFailure() { var exception = Assert.Throws( - () => Bind("""{ "Retry": { "Name": "checkout", "MaxRetries": -5, "Tier": 7 } }""")); + () => Bind("""{ "Retry": { "MaxRetries": -5 } }""")); // Assert.Throws is exact-type, so reaching here already rules out a validation failure. Assert.IsType(exception.InnerException); @@ -289,7 +190,7 @@ public void NamedOptions_ValidateOnlyTheirOwnName() { var services = new ServiceCollection(); services.AddOptions("configured").BindStrongTypes(Section(FullyConfigured)); - services.AddOptions("incomplete").BindStrongTypes(Section("""{ "Retry": { "Tier": 7 } }""")); + services.AddOptions("incomplete").BindStrongTypes(Section("""{ "Retry": { } }""")); var monitor = services.BuildServiceProvider().GetRequiredService>(); @@ -299,19 +200,13 @@ public void NamedOptions_ValidateOnlyTheirOwnName() // ── Nullable reference types disabled ─────────────────────────────── - /// - /// An options class from an assembly compiled without nullable reference types declares no - /// intent for a reference wrapper, so it is treated as optional rather than guessed at. The - /// struct wrapper still carries its own distinction and is still required. - /// + /// An options class from an assembly compiled without nullable reference types declares no intent, so nothing is enforced rather than guessed at. [Fact] - public void UnannotatedAssembly_ReferenceWrapperIsOptional_StructWrapperIsStillRequired() + public void UnannotatedAssembly_IsNotPoliced() { - var exception = BindExpectingFailure("""{ "Retry": { "Tier": 7 } }"""); - var failures = string.Join(" | ", exception.Failures); + var options = Bind("""{ "Retry": { } }"""); - Assert.Contains("Retry:MaxRetries", failures, StringComparison.Ordinal); - Assert.DoesNotContain("Retry:Name", failures, StringComparison.Ordinal); + Assert.Null(options.Name); } // ── Guard rails ───────────────────────────────────────────────────── diff --git a/src/StrongTypes.Configuration/NonNullableOptionsValidator.cs b/src/StrongTypes.Configuration/NonNullableOptionsValidator.cs new file mode 100644 index 00000000..490754eb --- /dev/null +++ b/src/StrongTypes.Configuration/NonNullableOptionsValidator.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace StrongTypes.Configuration; + +/// Fails validation when a property of declared non-nullable is null once bound. +/// The options type being bound. +internal sealed class NonNullableOptionsValidator(string? name, IConfiguration section) : IValidateOptions + where TOptions : class +{ + // Per-type and immutable, so this runs once in the static constructor — which also keeps + // NullabilityInfoContext, which is not thread-safe, on a single thread. + private static readonly PropertyInfo[] MustNotBeNull = FindNonNullableReferenceProperties(); + + public ValidateOptionsResult Validate(string? optionsName, TOptions options) + { + // A named registration validates only its own name; an unnamed one covers all. + if (name is not null && name != optionsName) + { + return ValidateOptionsResult.Skip; + } + + List? failures = null; + + foreach (var property in MustNotBeNull) + { + if (property.GetValue(options) is not null) + { + continue; + } + + failures ??= []; + failures.Add( + $"'{Path(property.Name)}' is null. Configure it, give {typeof(TOptions).Name}.{property.Name} " + + $"a default, or declare it nullable."); + } + + return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + private string Path(string propertyName) => + section is IConfigurationSection { Path.Length: > 0 } s ? $"{s.Path}:{propertyName}" : propertyName; + + /// + /// Only a non-nullable reference property can end up in a state its own declaration forbids. + /// + /// + /// A value type is skipped because it has no invalid state to reach: an unconfigured + /// bool is false and an unconfigured Positive<int> is 1 — a + /// default, and a value the type is perfectly happy to hold. Only null in something that + /// said it would never be null is a contradiction, and only a reference can manage it. + /// + /// A property in an assembly compiled without nullable reference types reads as + /// and is skipped too: with no intent declared there is + /// nothing to enforce. + /// + /// + private static PropertyInfo[] FindNonNullableReferenceProperties() + { + var nullability = new NullabilityInfoContext(); + + var result = new List(); + foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetSetMethod() is null || property.GetIndexParameters().Length > 0) + { + continue; + } + if (property.PropertyType.IsValueType) + { + continue; + } + if (nullability.Create(property).WriteState != NullabilityState.NotNull) + { + continue; + } + result.Add(property); + } + return [.. result]; + } +} diff --git a/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs b/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs index e4c4780c..21e9bdde 100644 --- a/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs +++ b/src/StrongTypes.Configuration/OptionsBuilderExtensions.cs @@ -6,41 +6,36 @@ namespace StrongTypes.Configuration; public static class OptionsBuilderExtensions { - /// Binds to and requires a configuration key for every property that declares it expects a value. + /// Binds to and fails when a property declared non-nullable is null once bound. /// The options builder to bind. /// The configuration section to bind from. /// , for chaining — pair with ValidateOnStart() to fail the host rather than the first request that reads the options. /// /// - /// A property is required when it is not nullable and the options class gives it no default of - /// its own. Everything else is optional: + /// The binder assigns nothing for an absent key, so a property it never reaches keeps whatever + /// the options class gave it — and a NonEmptyString that was given nothing is null, + /// which is precisely what the type says it can never be. Nullable reference annotations already + /// state which properties that applies to, so no attribute has to repeat it: /// /// - /// public NonEmptyString Name { get; set; } = null!; // required — null! declares no default - /// public NonEmptyString? Nickname { get; set; } // optional — nullable - /// public Positive<int> MaxRetries { get; set; } // required - /// public Positive<int>? Score { get; set; } // optional — nullable - /// public string Endpoint { get; set; } = "https://x.test"; // optional — has a default + /// public NonEmptyString Name { get; set; } = null!; // fails unless configured + /// public NonEmptyString? Nickname { get; set; } // nullable — fine + /// public string Endpoint { get; set; } = "https://x.test"; // has a default — never null /// /// - /// Every property type is checked, not only Kalicz.StrongTypes wrappers: opting in says the - /// options class should be fully configured, and a missing string is as silent as a - /// missing NonEmptyString. + /// Every reference property is covered, not only Kalicz.StrongTypes wrappers: a string + /// declared non-nullable is as broken by a missing key as a NonEmptyString is. /// /// - /// This is about the key that is absent. A wrapper's invariant constrains every value - /// it can hold; it cannot make the binder assign one, so an unconfigured NonEmptyString - /// is null and an unconfigured Positive<int> is 1 — its default, which - /// no [Required] can tell from a configured 1. This checks the section for each - /// key instead of checking the bound object for a null, so both cases fail. A key that is - /// present but invalid still fails while binding, with the invariant's own message. + /// A value type is not checked, because it has no invalid state to reach — an unconfigured + /// Positive<int> is 1 and an unconfigured bool is false, both + /// values those types are happy to hold. If "not configured" has to be distinguishable from a + /// configured default, declare it nullable (Positive<int>?) and check for null + /// yourself. /// /// - /// Two declarations cannot be read and are treated as required: a value type whose intended - /// default is the CLR default (bool Enabled { get; set; } = false), and — for the - /// nullable half of the rule — a reference property in an assembly compiled without nullable - /// reference types, which carries no annotation and is instead treated as optional. Declare a - /// property nullable when it is optional and neither applies. + /// This is about the key that is absent. A key that is present but invalid still fails while + /// binding, with the invariant's own message. /// /// /// or is null. @@ -52,7 +47,7 @@ public static OptionsBuilder BindStrongTypes(this OptionsBui builder.Bind(section); builder.Services.AddSingleton>( - new RequiredConfigurationKeysValidator(builder.Name, section)); + new NonNullableOptionsValidator(builder.Name, section)); return builder; } diff --git a/src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs b/src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs deleted file mode 100644 index c97ba827..00000000 --- a/src/StrongTypes.Configuration/RequiredConfigurationKeysValidator.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Reflection; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; - -namespace StrongTypes.Configuration; - -/// Fails validation when a property of that must be configured has no key in the bound section. -/// The options type being bound. -internal sealed class RequiredConfigurationKeysValidator(string? name, IConfiguration section) : IValidateOptions - where TOptions : class -{ - // Per-type and immutable, so this runs once in the static constructor — - // which also keeps NullabilityInfoContext, which is not thread-safe, on one thread. - private static readonly PropertyInfo[] RequiredProperties = FindPropertiesRequiringConfiguration(); - - public ValidateOptionsResult Validate(string? optionsName, TOptions options) - { - // A named registration validates only its own name; an unnamed one covers all. - if (name is not null && name != optionsName) - { - return ValidateOptionsResult.Skip; - } - - List? failures = null; - - foreach (var property in RequiredProperties) - { - // Exists() rather than a null Value: a collection or nested object is a section - // with children and no value of its own, and is configured all the same. - if (section.GetSection(property.Name).Exists()) - { - continue; - } - - failures ??= []; - failures.Add( - $"'{Path(property.Name)}' is required but was not configured. Give {typeof(TOptions).Name}.{property.Name} " + - $"a default, or declare it nullable, if it is optional."); - } - - return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); - } - - private string Path(string propertyName) => - section is IConfigurationSection { Path.Length: > 0 } s ? $"{s.Path}:{propertyName}" : propertyName; - - /// - /// A property must be configured when its declaration says a value is expected — it is not - /// nullable — and the options class supplies none of its own. - /// - private static PropertyInfo[] FindPropertiesRequiringConfiguration() - { - var declaredDefaults = TryConstructProbe(); - var nullability = new NullabilityInfoContext(); - - var required = new List(); - foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (property.GetSetMethod() is null || property.GetIndexParameters().Length > 0) - { - continue; - } - if (IsOptional(property, nullability) || SuppliesItsOwnDefault(property, declaredDefaults)) - { - continue; - } - required.Add(property); - } - return [.. required]; - } - - /// A fresh instance, to read what the options class declares before configuration touches it. - private static TOptions? TryConstructProbe() - { - try - { - return Activator.CreateInstance(); - } - catch (Exception e) when (e is MissingMethodException or MemberAccessException or TargetInvocationException) - { - // Binding needs a parameterless constructor anyway, so this is unreachable in practice - // and would already have failed. Treating every property as having no default is the - // strict reading, and the failure it produces names the key either way. - return null; - } - } - - /// - /// True when the options class initialises the property to anything other than the CLR default, - /// which is the only way it can say "optional, and here is the fallback". - /// - /// - /// A value type whose intended default is the CLR default — bool Enabled { get; set; } = false - /// — is indistinguishable from one with no initialiser at all, and so is required. Declare it - /// nullable to make it optional. - /// - private static bool SuppliesItsOwnDefault(PropertyInfo property, TOptions? declaredDefaults) - { - if (declaredDefaults is null) - { - return false; - } - - var declared = property.GetValue(declaredDefaults); - var clrDefault = property.PropertyType.IsValueType ? Activator.CreateInstance(property.PropertyType) : null; - return !Equals(declared, clrDefault); - } - - /// - /// A reference property in an assembly compiled without nullable reference types carries no - /// annotation, so it reads as and is treated as optional: - /// with no intent declared there is nothing to enforce. - /// - private static bool IsOptional(PropertyInfo property, NullabilityInfoContext nullability) - { - if (Nullable.GetUnderlyingType(property.PropertyType) is not null) - { - return true; - } - if (property.PropertyType.IsValueType) - { - return false; - } - return nullability.Create(property).WriteState != NullabilityState.NotNull; - } -} diff --git a/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj b/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj index eeab0cd2..0c384381 100644 --- a/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj +++ b/src/StrongTypes.Configuration/StrongTypes.Configuration.csproj @@ -11,7 +11,7 @@ Kalicz.StrongTypes.Configuration KaliCZ Copyright © 2026 KaliCZ - Configuration binding support for Kalicz.StrongTypes. Adds OptionsBuilder<T>.BindStrongTypes(), which binds a section and then fails when any property that expects a value — not nullable, and given no default of its own — has no configuration key. Catches what [Required] cannot: default(Positive<int>) is 1, an ordinary value rather than null. + Configuration binding support for Kalicz.StrongTypes. Adds OptionsBuilder<T>.BindStrongTypes(), which binds a section and then fails when a property declared non-nullable is null once bound — what an absent key otherwise leaves silently, because binding succeeds without assigning. Reads intent from nullable reference annotations, so no [Required] attribute per property. StrongTypes, Configuration, Options, IOptions, Validation, NonEmptyString MIT false diff --git a/src/StrongTypes.Configuration/readme.md b/src/StrongTypes.Configuration/readme.md index 59896447..eeeb03ee 100644 --- a/src/StrongTypes.Configuration/readme.md +++ b/src/StrongTypes.Configuration/readme.md @@ -2,7 +2,7 @@ [![NuGet version](https://img.shields.io/nuget/v/Kalicz.StrongTypes.Configuration?label=nuget)](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) [![Downloads](https://img.shields.io/nuget/dt/Kalicz.StrongTypes.Configuration?label=downloads)](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) [![License](https://img.shields.io/github/license/KaliCZ/StrongTypes)](https://github.com/KaliCZ/StrongTypes/blob/main/license.txt) -Makes an **unconfigured** strong type fail instead of quietly defaulting. +Stops an options class binding a non-nullable property to `null`. ```csharp builder.Services.AddOptions() @@ -17,87 +17,63 @@ bind: every wrapper carries a `TypeConverter`, so values bind and invalid ones t invariant's own message. **This package is only about the key that isn't there.** A wrapper's invariant constrains every value it can hold. It cannot make the binder assign one — the -binder reaches in through reflection and never calls `Create`. So: +binder reaches in through reflection and never calls `Create` — and for an absent key it assigns +nothing at all. So: ```csharp public sealed class RetryOptions { - public NonEmptyString Name { get; set; } // unconfigured -> null - public Positive MaxRetries { get; set; } // unconfigured -> 1 + public NonEmptyString Name { get; set; } = null!; // unconfigured -> null } ``` -`ValidateOnStart()` does not help: binding an absent key *succeeds*, it just doesn't assign, so -nothing is raised. `[Required]` catches `Name`, because it is null — but **cannot** catch -`MaxRetries`, because `default(Positive)` is `1`, an ordinary invariant-satisfying value that -looks exactly like a configured one. Neither does C#'s `required` keyword, which the binder's -reflection walks straight past. +`null`, in the one type whose entire purpose is to never be null. `ValidateOnStart()` does not help: +binding an absent key *succeeds*, it just doesn't assign, so nothing is raised. C#'s `required` +doesn't either — it's a compile-time rule and the binder's reflection walks past it. `[Required]` +does work, but only if you remember it on every property. ## What it does -`BindStrongTypes()` binds the section, then asks **the configuration** whether each key is present -— rather than asking the bound object whether it looks null. That question has an answer for -structs: - -``` -OptionsValidationException: 'Retry:MaxRetries' is required but was not configured. - Declare RetryOptions.MaxRetries nullable if it is optional. -``` - -The declaration is the spec — no attributes. A property is **required when it is not nullable and -the options class gives it no default of its own**: +Fails when a property **declared non-nullable** is null after binding. Your nullable reference +annotations already say which properties those are, so nothing has to repeat it: ```csharp -public NonEmptyString Name { get; set; } = null!; // required — null! declares no default -public NonEmptyString? Nickname { get; set; } // optional — nullable -public Positive MaxRetries { get; set; } // required -public Positive? Score { get; set; } // optional — nullable -public string Endpoint { get; set; } = "https://x.test"; // optional — has a default -public string ApiKey { get; set; } = null!; // required -public int Timeout { get; set; } // required -public int? Timeout { get; set; } // optional +public NonEmptyString Name { get; set; } = null!; // fails unless configured +public NonEmptyString? Nickname { get; set; } // nullable — fine +public string ApiKey { get; set; } = null!; // fails unless configured +public string Endpoint { get; set; } = "https://x.test"; // has a default — never null ``` -**Every property is checked, not only the wrappers.** Opting in says this options class should be -fully configured, and a missing `string` is exactly as silent as a missing `NonEmptyString`. - -It also rejects an explicit `null`, which plain binding does not — `"Name": null` otherwise leaves -a non-nullable `NonEmptyString` holding `null`, because nullability is erased before the binder runs. - -## What counts as configured - -The question is whether the **section** holds anything, not whether the bound value ends up non-null. -For a scalar the two coincide; for a collection or nested object they need care, because those are -sections with *children* and no value of their own — `"Items": [ "a", "b" ]` flattens to -`Retry:Items:0` and `Retry:Items:1` beneath a valueless `Retry:Items`. - -| in `appsettings.json` | configured? | -| --------------------- | ----------- | -| `"Items": [ "a" ]` | yes — has children | -| `"Items": []` | yes — recorded as an empty value, and a deliberate one | -| `"Nested": { "X": 1 }`| yes — has children | -| `"Nested": { }` | no — neither value nor children | -| `"Name": null` | no — the key is there but says nothing | -| key absent | no | +``` +OptionsValidationException: 'Retry:Name' is null. Configure it, give RetryOptions.Name a default, + or declare it nullable. +``` -One wrinkle worth knowing: `"Items": []` satisfies the requirement, but the binder still leaves the -property `null` rather than building an empty list. Presence and non-nullness are different -questions, and this answers the first. +**Every reference property is covered, not only the wrappers** — a `string` declared non-nullable is +as broken by a missing key as a `NonEmptyString` is. A declared default needs no configuration and +no annotation: it simply isn't null. Pair it with `ValidateOnStart()`. On its own the failure is still lazy — it surfaces on the first read of `IOptions.Value`, not at startup. -## Two declarations it cannot read +## Value types are not checked, and don't need to be -- **A value type whose intended default is the CLR default.** `bool Enabled { get; set; } = false` - is indistinguishable from `bool Enabled { get; set; }`, so it is required. Declare it `bool?` to - make it optional. -- **A reference property in an assembly with `disable`.** It carries no - annotation, so it is treated as **optional** — with no intent declared there is nothing to - enforce. Struct properties are unaffected: `Positive` vs `Positive?` lives in the type - and is always readable. +An unconfigured `Positive` is `1`; an unconfigured `bool` is `false`. Those are defaults, and +they are values those types are perfectly happy to hold — there is no contradiction to find. Only +`null` in something that promised never to be null is one, and only a reference can manage it. + +So this will not tell you that you forgot to configure `MaxRetries`. If "not configured" has to be +distinguishable from a configured default, declare it `Positive?` and check for null yourself +— that is the only way the CLR gives you to say it. ## When you don't need it -- Every property on your options classes is nullable or has a default, so nothing is required. -- You already have an `IValidateOptions` covering presence. +- Every reference property on your options classes is nullable or has a default. +- You already use `[Required]` with `ValidateDataAnnotations()`, which covers the same ground with + an attribute per property. + +## Nullable reference types + +Intent is read from the assembly's nullable annotations. An options class in a project with +`disable` carries none, so nothing on it is enforced — with no intent declared +there is nothing to enforce. From ce35833b958ec3023f95e4e228168f749c4b61f0 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Wed, 15 Jul 2026 09:47:06 +0200 Subject: [PATCH 12/24] Finish the docs sweep for BindStrongTypes The skill reference still printed the old failure ("'Retry:MaxRetries' is required but was not configured") against a value type that is no longer checked, and named RetryOptions.Name in a failure the file's own class declares nullable. Both are gone. Pin the exact message in a test so the documented text is copied from a passing assertion rather than read off the format string. Add Kalicz.StrongTypes.Configuration to the package-layout diagrams, which claimed to show every package and shipped without it. Co-Authored-By: Claude Opus 4.8 --- Skill/SKILL.md | 2 +- Skill/references/configuration.md | 33 +++++++++++-------- docs/diagrams/package-layout-dark.svg | 18 ++++++---- docs/diagrams/package-layout.svg | 18 ++++++---- .../BindStrongTypesTests.cs | 9 ++--- 5 files changed, 47 insertions(+), 33 deletions(-) diff --git a/Skill/SKILL.md b/Skill/SKILL.md index caf509d9..c2b4a5b7 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -64,7 +64,7 @@ demand when about to write code against that surface. | Topic | Reference | | ------------------------------------------------------------- | ------------------------------- | | Enum extensions (`Enum.Parse`, `AllValues`, `AllFlagValues`, `GetFlags`) and `string?` parsers (`AsInt`, `AsGuid`, `AsEnum`, …) | `references/parsing.md` | -| Configuration / `IOptions` binding — zero setup, invariant doubles as validation, `ValidateOnStart()` | `references/configuration.md` | +| Configuration / `IOptions` binding — zero setup, invariant doubles as validation, `ValidateOnStart()`, and `BindStrongTypes()` (`Kalicz.StrongTypes.Configuration`) for the absent key | `references/configuration.md` | | `T?.Map`, `bool.MapTrue` / `MapFalse` | `references/map.md` | | `IEnumerable` extensions, `ReadOnlyList`, `Result` partition helpers | `references/collections.md` | | EF Core: `UseStrongTypes` value converters, interval column mapping, `.Unwrap()` LINQ marker | `references/efcore.md` | diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 559c7288..ff0156ac 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -71,25 +71,29 @@ Add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.S and the declaration becomes the spec — no `[Required]`, no `ValidateDataAnnotations()`: ```csharp -builder.Services.AddOptions() - .BindStrongTypes(builder.Configuration.GetSection("Retry")) +builder.Services.AddOptions() + .BindStrongTypes(builder.Configuration.GetSection("Client")) .ValidateOnStart(); ``` -``` -OptionsValidationException: 'Retry:MaxRetries' is required but was not configured. - Declare RetryOptions.MaxRetries nullable if it is optional. -``` - It fails when a property **declared non-nullable is null after binding** — the one state an absent key can leave that the declaration forbids. Your nullable annotations already say which properties those are, so no `[Required]` has to repeat it: ```csharp -public NonEmptyString Name { get; set; } = null!; // fails unless configured -public NonEmptyString? Nickname { get; set; } // nullable — fine -public string ApiKey { get; set; } = null!; // fails unless configured -public string Endpoint { get; set; } = "https://x.test"; // has a default — never null +public sealed class ClientOptions +{ + public NonEmptyString Name { get; set; } = null!; // fails unless configured + public NonEmptyString? Nickname { get; set; } // nullable — fine + public string ApiKey { get; set; } = null!; // fails unless configured + public string Endpoint { get; set; } = "https://x.test"; // has a default — never null + public Positive MaxRetries { get; set; } // never checked — see below +} +``` + +``` +OptionsValidationException: 'Client:Name' is null. Configure it, give ClientOptions.Name a default, + or declare it nullable. ``` **Every reference property is covered, not only the wrappers** — a non-nullable `string` is as @@ -103,9 +107,10 @@ contradiction to catch, so this will not tell you that you forgot `MaxRetries`. An options class in an assembly with `disable` declares no intent, so nothing on it is enforced. -Analyzer **ST0004** flags a plain `Bind` / `Configure` on an options type that needs this, with a -code fix that rewrites the call. It stays quiet for a reference wrapper already carrying -`[Required]`, since that case genuinely is covered. +Analyzer **ST0004** flags a plain `Bind` / `Configure` that would leave a non-nullable wrapper null, +with a code fix that rewrites the call. It reports only this library's own wrappers, never a plain +`string`, so it sees less than the check it points you at. It also stays quiet for a property +already carrying `[Required]`, which genuinely covers that case. The rest of this section describes what happens **without** that package. diff --git a/docs/diagrams/package-layout-dark.svg b/docs/diagrams/package-layout-dark.svg index a107b062..b5d5c945 100644 --- a/docs/diagrams/package-layout-dark.svg +++ b/docs/diagrams/package-layout-dark.svg @@ -1,4 +1,4 @@ - + - /// public NonEmptyString Name { get; set; } = null!; // fails unless configured - /// public NonEmptyString? Nickname { get; set; } // nullable — fine - /// public string Endpoint { get; set; } = "https://x.test"; // has a default — never null - /// - /// - /// Every reference property is covered, not only Kalicz.StrongTypes wrappers: a string - /// declared non-nullable is as broken by a missing key as a NonEmptyString is. - /// - /// - /// A value type is not checked, because it has no invalid state to reach — an unconfigured - /// Positive<int> is 1 and an unconfigured bool is false, both - /// values those types are happy to hold. If "not configured" has to be distinguishable from a - /// configured default, declare it nullable (Positive<int>?) and check for null - /// yourself. - /// - /// - /// This is about the key that is absent. A key that is present but invalid still fails while - /// binding, with the invariant's own message. - /// - /// + /// + /// Binds to , then fails validation when a + /// non-nullable reference property is left null at any depth. Value types are never required — an + /// unconfigured Positive<int> is 1. Pair with ValidateOnStart() to fail at + /// startup rather than on first read. + /// /// or is null. public static OptionsBuilder BindStrongTypes(this OptionsBuilder builder, IConfiguration section) where TOptions : class diff --git a/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs b/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs index 185b23c0..ffe5f641 100644 --- a/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs +++ b/src/StrongTypes.Tests/ComponentModel/ConfigurationBindingTests.cs @@ -10,15 +10,9 @@ namespace StrongTypes.Tests; /// -/// Without a TypeConverter, ConfigurationBinder cannot turn "5" into a strong -/// type and silently leaves the property at its default rather than failing — so these assert the -/// bound value, not merely that binding returned. +/// Without a TypeConverter, ConfigurationBinder silently leaves the property at its +/// default rather than failing — so these assert the bound value, not merely that binding returned. /// -/// -/// Every scenario runs through both 's Get<T> and the -/// pipeline. They share a conversion path and so far agree on -/// every row, but that is asserted here rather than assumed. -/// public class ConfigurationBindingTests { public enum BindingPath @@ -40,7 +34,6 @@ private sealed class RetryOptions public Digit Tier { get; set; } } - /// JSON property assignments placed inside the Retry object. private static IConfiguration Config(string settings) => new ConfigurationBuilder() .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes("{\"Retry\":{" + settings + "}}"))) @@ -99,7 +92,7 @@ public void BoundValueComesFromConfiguration_NotTheDefault(int configured) // ── Absent vs. explicit null ──────────────────────────────────────── - /// An absent key leaves the property exactly as the options class constructed it — here a non-null initialiser. A real options class rarely has one; see for what absence yields then. + /// An absent key leaves the property exactly as the options class constructed it — here a non-null initialiser. [Theory] [MemberData(nameof(Paths))] public void AbsentKey_LeavesWhateverThePropertyAlreadyHeld(BindingPath path) @@ -128,12 +121,7 @@ public void ExplicitNull_OnAStructWrapper_LeavesTheDefault(BindingPath path) public void ExplicitNull_OnANullableReferenceWrapper_IsNull(BindingPath path) => Assert.Null(Bind(path, "\"Nickname\": null").Nickname); - /// - /// A null in config nulls even a non-nullable reference property, because nullability is - /// erased by the time the binder runs — NonEmptyString is no different from string - /// here. The invariant holds for every value the type can hold; it cannot stop the binder - /// assigning none at all. Prefer an absent key, or validate on start. - /// + /// A null in config nulls even a non-nullable reference property, because nullability is erased by the time the binder runs — NonEmptyString is no different from string here. [Theory] [MemberData(nameof(Paths))] public void ExplicitNull_DefeatsANonNullableReferenceWrapper(BindingPath path) => @@ -159,12 +147,7 @@ public void EmptyString_OnAReferenceWrapper_Throws_NullableOrNot(BindingPath pat public void EmptyString_OnANonNullableStructWrapper_Throws(BindingPath path) => Assert.Throws(() => Bind(path, "\"MaxRetries\": \"\"")); - /// - /// The one asymmetry: empty binds to null here rather than throwing, because a nullable - /// struct resolves to the BCL's NullableConverter, which maps empty to null before our - /// converter is consulted. WPF, which unwraps Nullable<T> itself, does not do this — - /// the same "" raises a validation error there. - /// + /// The one asymmetry: empty binds to null here rather than throwing. [Theory] [MemberData(nameof(Paths))] public void EmptyString_OnANullableStructWrapper_IsNull(BindingPath path) => @@ -207,10 +190,7 @@ public void ValueInTheWrongFormat_Throws(BindingPath path, string settings) => // ── When the failure surfaces ─────────────────────────────────────── - /// - /// Binding is lazy: the failure surfaces on first IOptions.Value, not at - /// BuildServiceProvider. Callers wanting a startup failure add ValidateOnStart. - /// + /// Binding is lazy: the failure surfaces on first IOptions.Value, not at BuildServiceProvider. [Fact] public void InvalidValue_DoesNotThrowUntilOptionsAreRead() { diff --git a/src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs b/src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs index 5722c06a..8ec398d4 100644 --- a/src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs +++ b/src/StrongTypes.Tests/ComponentModel/UnconfiguredOptionsTests.cs @@ -8,15 +8,7 @@ namespace StrongTypes.Tests; -/// -/// What a strong type holds when its key is simply not in config. A wrapper's invariant constrains -/// every value it can hold; it cannot make the binder assign one, so an unconfigured property is a -/// hole the type system does not plug — and the two kinds of wrapper leave a different shape of hole. -/// -/// -/// The options classes here deliberately carry no property initialisers. An initialiser hides the -/// whole problem: the binder leaves it in place and the property looks configured. -/// +/// What a strong type holds when its key is simply not in config. public class UnconfiguredOptionsTests { private sealed class RetryOptions @@ -35,7 +27,7 @@ private sealed class RequiredRetryOptions public Digit Tier { get; set; } } - /// Only Tier is configured; the section exists so the binder produces an instance. + /// The section exists so the binder produces an instance rather than returning null. private const string OnlyTierConfigured = "{\"Retry\":{\"Tier\":7}}"; private static IConfigurationSection Section(string json) => @@ -57,7 +49,7 @@ private static TOptions Bind(string json, bool validate = false) where public void UnconfiguredReferenceWrapper_IsNull() => Assert.Null(Bind(OnlyTierConfigured).Name); - /// A struct wrapper falls back to default, which satisfies the invariant and so reads as a deliberately configured value. This is the shape the silent-config bug took. + /// A struct wrapper falls back to default, which satisfies the invariant and so reads as a deliberately configured value. [Fact] public void UnconfiguredStructWrapper_IsItsDefault() { @@ -85,8 +77,7 @@ public void MissingKeys_BindWithoutError() /// /// [Required] plus ValidateDataAnnotations() catches a missing reference wrapper /// and a missing nullable struct wrapper — both are null — but cannot catch a missing - /// non-nullable struct wrapper, whose default is an ordinary value and never null. Declare a - /// struct wrapper nullable when "not configured" has to be detectable. + /// non-nullable struct wrapper, whose default is an ordinary value and never null. /// [Fact] public void Required_CatchesNullsOnly_NotAStructsDefault() diff --git a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs index d59f2d9c..def69c3c 100644 --- a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs +++ b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs @@ -118,9 +118,6 @@ public void TryParse_Span_BreachedInvariant_ReturnsFalse() } // ── Generic constraints ───────────────────────────────────────────── - // - // What the span interfaces buy that reaching for .Value cannot: a caller's - // generic code constrained on them can accept a wrapper at all. private static string Render(T value, string format) where T : IFormattable => value.ToString(format, American); diff --git a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs index 966af14b..7b0cdd46 100644 --- a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs +++ b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs @@ -39,21 +39,18 @@ public Digit Tier set { _tier = value; Raise(); } } - /// Culture-sensitive: a decimal separator differs between cultures, so this is what pins ConverterCulture round-tripping. public Positive Salary { get => _salary; set { _salary = value; Raise(); } } - /// A nullable reference wrapper — WPF sees the property type as , nullability being compile-time only. public NonEmptyString? Nickname { get => _nickname; set { _nickname = value; Raise(); } } - /// A nullable struct wrapper — WPF routes this through the BCL's NullableConverter. public Positive? Score { get => _score; diff --git a/src/StrongTypes.Wpf.Tests/BindingTests.cs b/src/StrongTypes.Wpf.Tests/BindingTests.cs index 1afe1bf0..0e873844 100644 --- a/src/StrongTypes.Wpf.Tests/BindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/BindingTests.cs @@ -8,11 +8,6 @@ namespace StrongTypes.Wpf.Tests; -/// -/// WPF routes string → T through and never -/// consults IParsable<T>, so these are the only coverage proving the core -/// [TypeConverter] satisfies the binding engine. No registration call is made anywhere. -/// public class NonEmptyStringBindingTests { [Fact] @@ -185,7 +180,6 @@ public void TwoWay_NonNumericInput_DoesNotMutateSourceAndRaisesValidationError() } } -/// A non-generic struct wrapper — resolves its converter by attribute rather than through StrongTypeConverter's open-generic bootstrap. public class DigitBindingTests { [Fact] diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs index 36d39d46..a64fda1b 100644 --- a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -10,18 +10,14 @@ namespace StrongTypes.Wpf.Tests; -/// -/// A binding hands the converter one culture for both directions, so display and write-back must -/// agree on it — and on the binding's culture, not the host's. -/// +/// Display and write-back both use the binding's culture, not the host's. public class CultureBindingTests { private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); /// - /// Pinned, and deliberately not a decimal-comma culture: a converter that formatted with the - /// ambient culture instead of the binding's would still pass every assertion below on a - /// de-DE — or cs-CZ — host, because the two separators happen to agree. + /// Pinned to a non-decimal-comma culture on purpose: a converter that formatted with the host + /// culture instead of the binding's would still pass every assertion below on a de-DE host. /// private static readonly CultureInfo Host = CultureInfo.GetCultureInfo("en-US"); @@ -55,11 +51,7 @@ public void TwoWay_ParsesInTheBindingCulture() => Assert.Equal(Positive.Create(9876.5m), vm.Salary); }); - /// - /// Committing the displayed text unedited must be a no-op. When the directions disagree on - /// culture it is not: the box shows "1234.5", de-DE reads the dot as a group separator, and the - /// view model silently becomes 12345. - /// + /// Committing the displayed text unedited must be a no-op. [Fact] public void DisplayedTextRoundTripsBackToTheSameValue() => RunOnAmericanHost(() => @@ -74,7 +66,6 @@ public void DisplayedTextRoundTripsBackToTheSameValue() => Assert.False(Validation.GetHasError(textBox)); }); - /// The host's own culture still drives a binding that does not name one. [Fact] public void NoConverterCulture_FormatsInTheHostCulture() => RunOnAmericanHost(() => diff --git a/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs b/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs index 86791b79..242adfae 100644 --- a/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs @@ -8,12 +8,6 @@ namespace StrongTypes.Wpf.Tests; -/// -/// The two nullable shapes resolve their converter differently — a nullable struct wrapper goes -/// through the BCL's NullableConverter, which we do not own, while a nullable reference -/// wrapper hits our converter directly because nullability is erased at runtime. Both are pinned -/// here because neither is obvious from the property declaration. -/// public class NullableStructBindingTests { [Fact] @@ -57,13 +51,6 @@ public void TwoWay_ValidInput_UpdatesSource() }); } - /// - /// Clearing the box does not null the source. WPF unwraps Nullable<T> and asks - /// TypeDescriptor for the underlying type's converter, so the BCL's NullableConverter - /// — which would map empty to null, and does for configuration binding — is never consulted; - /// "" reaches Positive<int>.Parse and fails. Surprising, but long-standing: - /// no converter has ever been registered against Nullable<T> here. - /// [Fact] public void TwoWay_ClearedInput_RaisesValidationErrorRatherThanNulling() { diff --git a/src/StrongTypes.Wpf.Tests/TestSetup.cs b/src/StrongTypes.Wpf.Tests/TestSetup.cs index ff9d8bcb..e0c2db6d 100644 --- a/src/StrongTypes.Wpf.Tests/TestSetup.cs +++ b/src/StrongTypes.Wpf.Tests/TestSetup.cs @@ -7,9 +7,7 @@ namespace StrongTypes.Wpf.Tests; internal static class TestSetup { - // No strong-type registration: binding must work off the [TypeConverter] the - // core package puts on each type. An Application instance is still required - // for WPF's binding engine to resolve resources. + // An Application instance is required for WPF's binding engine to resolve resources. [ModuleInitializer] internal static void Init() => StaThread.Run(() => new Application()); } diff --git a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs index cf1526b0..47f112e0 100644 --- a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs +++ b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs @@ -8,7 +8,7 @@ namespace StrongTypes; /// Converts between and via , honouring the culture it is handed. /// A strong type that implements . -/// Invalid input surfaces as whatever T.Parse throws — for a Kalicz.StrongTypes wrapper, the naming the broken invariant. A generic type cannot name a closed converter in an attribute argument; it uses instead. +/// Invalid input surfaces as whatever T.Parse throws — for a Kalicz.StrongTypes wrapper, the naming the broken invariant. public sealed class ParsableTypeConverter : TypeConverter where T : IParsable { diff --git a/src/StrongTypes/ComponentModel/StrongTypeConverter.cs b/src/StrongTypes/ComponentModel/StrongTypeConverter.cs index 934d68ff..877c0e23 100644 --- a/src/StrongTypes/ComponentModel/StrongTypeConverter.cs +++ b/src/StrongTypes/ComponentModel/StrongTypeConverter.cs @@ -7,12 +7,11 @@ namespace StrongTypes; /// Converts between and a strong type that implements , for generic types that cannot name a closed in an attribute argument. -/// Behaves exactly like ; a non-generic type should name that directly instead. public sealed class StrongTypeConverter : TypeConverter { private readonly TypeConverter _inner; - /// The closed strong type to convert. supplies this from the type carrying the attribute. + /// The closed strong type to convert. /// does not implement . public StrongTypeConverter(Type type) { From 69db45e037662024ee4bbf2d6e5c658571a08f35 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 16:54:23 +0200 Subject: [PATCH 17/24] Offer the Bind -> BindStrongTypes fix even without the package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix lives in the core analyzer assembly, so nothing about it needs the Configuration package to exist — it was only gated behind a reference check. That hid it in exactly the case where a user most needs the nudge: they see ST0004 but no fix, with no hint that a package is the missing piece. Drop the gate. The rewrite won't compile until the package is added, and the fix title now names it, so the IDE's own missing-package flow takes it from there. Co-Authored-By: Claude Opus 4.8 --- .../UseBindStrongTypesCodeFixProviderTests.cs | 7 ++++--- .../UseBindStrongTypesCodeFixProvider.cs | 14 +++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs b/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs index 23c5a518..56e01673 100644 --- a/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs +++ b/src/StrongTypes.Analyzers.Tests/UseBindStrongTypesCodeFixProviderTests.cs @@ -55,9 +55,9 @@ public async Task Adds_the_configuration_using() Assert.Contains("using StrongTypes.Configuration;", fixedSource, StringComparison.Ordinal); } - /// Without the package the rewrite would not compile, so no fix is offered — the diagnostic's help link points at the package instead. + /// Offered even without the package — the rewrite won't compile until it is added, and the fix title names it. [Fact] - public async Task Not_offered_when_the_configuration_package_is_absent() + public async Task Offered_without_the_package_naming_it_in_the_title() { var actions = await DocumentCodeFixTester.RegisterFixesAsync( new UnvalidatedStrongTypeOptionsAnalyzer(), @@ -65,7 +65,8 @@ public async Task Not_offered_when_the_configuration_package_is_absent() BindSource, TestReferences.With(TestReferences.OptionsStack)); - Assert.Empty(actions); + var action = Assert.Single(actions); + Assert.Contains("Kalicz.StrongTypes.Configuration", action.Title, StringComparison.Ordinal); } /// Rewriting Configure<T> means restructuring to AddOptions<T>().BindStrongTypes(…); the diagnostic still fires, but the change is the caller's to make. diff --git a/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs b/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs index b67aaa80..ea0d151f 100644 --- a/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs +++ b/src/StrongTypes.Analyzers/UseBindStrongTypesCodeFixProvider.cs @@ -14,9 +14,9 @@ namespace StrongTypes.Analyzers; /// /// Code fix for : rewrites .Bind(section) to -/// .BindStrongTypes(section). Offered only when Kalicz.StrongTypes.Configuration is -/// referenced, and only for Bind — a Configure<T> call needs restructuring, so it -/// gets none. +/// .BindStrongTypes(section), for Bind only — a Configure<T> call needs +/// restructuring, so it gets none. When Kalicz.StrongTypes.Configuration is not referenced the +/// fix is still offered, with a title that names the package to add. /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseBindStrongTypesCodeFixProvider))] [Shared] @@ -33,7 +33,7 @@ public sealed class UseBindStrongTypesCodeFixProvider : CodeFixProvider public override async Task RegisterCodeFixesAsync(CodeFixContext context) { var compilation = await context.Document.Project.GetCompilationAsync(context.CancellationToken).ConfigureAwait(false); - if (compilation is null || !ReferencesConfigurationPackage(compilation)) + if (compilation is null) { return; } @@ -44,6 +44,10 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) return; } + var title = ReferencesConfigurationPackage(compilation) + ? "Use BindStrongTypes()" + : $"Use BindStrongTypes() (add {UnvalidatedStrongTypeOptionsAnalyzer.ConfigurationPackageId})"; + foreach (var diagnostic in context.Diagnostics) { var invocation = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true) @@ -58,7 +62,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) context.RegisterCodeFix( CodeAction.Create( - title: "Use BindStrongTypes()", + title: title, createChangedDocument: ct => UseBindStrongTypesAsync(context.Document, access, ct), equivalenceKey: nameof(UseBindStrongTypesCodeFixProvider)), diagnostic); From ce4528df6403c732d191d9318070e93958a98b46 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 16:54:23 +0200 Subject: [PATCH 18/24] Test the WPF culture path across a host x binding matrix One host culture proved little: WPF resolves a binding's culture as ConverterCulture ?? element.Language and ignores the thread culture entirely, so the single en-US host couldn't show the host was being ignored rather than merely matching. Run every case across en-US/de-DE/cs-CZ/ja-JP hosts crossed with the same set of binding cultures, and assert format, write-back, and round-trip all follow the binding culture. The no-ConverterCulture case now sets the element Language explicitly and checks the binding follows that, not the host. StaThread now shuts down each thread's Dispatcher: constructing a WPF element spins one up, and at this many cases the leftover foreground threads made the test host force-exit non-zero after every test had passed. Co-Authored-By: Claude Opus 4.8 --- .../CultureBindingTests.cs | 102 ++++++++++++------ src/StrongTypes.Wpf.Tests/StaThread.cs | 4 + 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs index a64fda1b..03fb397d 100644 --- a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -1,79 +1,111 @@ #nullable enable using System; +using System.Collections.Generic; using System.Globalization; using System.Windows.Controls; using System.Windows.Data; +using System.Windows.Markup; using StrongTypes.Wpf.TestApp; using Xunit; using static StrongTypes.Wpf.Tests.Bindings; namespace StrongTypes.Wpf.Tests; -/// Display and write-back both use the binding's culture, not the host's. +/// +/// WPF resolves a binding's culture as ConverterCulture ?? element.Language and never consults +/// the host thread culture — so every case runs the same value through a matrix of host thread +/// cultures crossed with binding cultures, proving the host is ignored. +/// public class CultureBindingTests { - private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + private static readonly string[] Cultures = ["en-US", "de-DE", "cs-CZ", "ja-JP"]; - /// - /// Pinned to a non-decimal-comma culture on purpose: a converter that formatted with the host - /// culture instead of the binding's would still pass every assertion below on a de-DE host. - /// - private static readonly CultureInfo Host = CultureInfo.GetCultureInfo("en-US"); - - private static void RunOnAmericanHost(Action body) => StaThread.Run(() => + public static IEnumerable HostAndBinding() { - CultureInfo.CurrentCulture = Host; - body(); - }); + foreach (var host in Cultures) + { + foreach (var binding in Cultures) + { + yield return [host, binding]; + } + } + } - [Fact] - public void OneWay_FormatsInTheBindingCultureNotTheHostCulture() => - RunOnAmericanHost(() => + [Theory] + [MemberData(nameof(HostAndBinding))] + public void Display_usesTheConverterCulture(string hostName, string bindingName) + { + var binding = CultureInfo.GetCultureInfo(bindingName); + RunOn(hostName, () => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; - var textBox = new TextBox(); - BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, German)); + var textBox = BoundWithConverterCulture(vm, binding); - Assert.Equal("1234,5", textBox.Text); + Assert.Equal(1234.5m.ToString(binding), textBox.Text); }); + } - [Fact] - public void TwoWay_ParsesInTheBindingCulture() => - RunOnAmericanHost(() => + [Theory] + [MemberData(nameof(HostAndBinding))] + public void WriteBack_parsesInTheConverterCulture(string hostName, string bindingName) + { + var binding = CultureInfo.GetCultureInfo(bindingName); + RunOn(hostName, () => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; - var textBox = new TextBox(); - BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, German)); + var textBox = BoundWithConverterCulture(vm, binding); - textBox.Text = "9876,5"; + textBox.Text = 9876.5m.ToString(binding); Assert.Equal(Positive.Create(9876.5m), vm.Salary); }); + } - /// Committing the displayed text unedited must be a no-op. - [Fact] - public void DisplayedTextRoundTripsBackToTheSameValue() => - RunOnAmericanHost(() => + /// Committing the displayed text unedited must not corrupt the value — the bug that shipped when the converter formatted in the host culture but parsed in the binding culture. + [Theory] + [MemberData(nameof(HostAndBinding))] + public void RoundTrip_committingDisplayedTextIsANoOp(string hostName, string bindingName) + { + var binding = CultureInfo.GetCultureInfo(bindingName); + RunOn(hostName, () => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; - var textBox = new TextBox(); - BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, German)); + var textBox = BoundWithConverterCulture(vm, binding); textBox.Text = textBox.Text; Assert.Equal(Positive.Create(1234.5m), vm.Salary); Assert.False(Validation.GetHasError(textBox)); }); + } - [Fact] - public void NoConverterCulture_FormatsInTheHostCulture() => - RunOnAmericanHost(() => + /// With no ConverterCulture the binding falls back to the element's Language, still never the host. + [Theory] + [MemberData(nameof(HostAndBinding))] + public void NoConverterCulture_usesTheElementLanguageNotTheHost(string hostName, string languageName) + { + var language = CultureInfo.GetCultureInfo(languageName); + RunOn(hostName, () => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; - var textBox = new TextBox(); + var textBox = new TextBox { Language = XmlLanguage.GetLanguage(languageName) }; BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm)); - Assert.Equal("1234.5", textBox.Text); + Assert.Equal(1234.5m.ToString(language), textBox.Text); }); + } + + private static void RunOn(string cultureName, Action body) => StaThread.Run(() => + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + body(); + }); + + private static TextBox BoundWithConverterCulture(PersonViewModel vm, CultureInfo converterCulture) + { + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, converterCulture)); + return textBox; + } } diff --git a/src/StrongTypes.Wpf.Tests/StaThread.cs b/src/StrongTypes.Wpf.Tests/StaThread.cs index 3a0dd258..d2dcf914 100644 --- a/src/StrongTypes.Wpf.Tests/StaThread.cs +++ b/src/StrongTypes.Wpf.Tests/StaThread.cs @@ -3,6 +3,7 @@ using System; using System.Runtime.ExceptionServices; using System.Threading; +using System.Windows.Threading; namespace StrongTypes.Wpf.Tests; @@ -19,6 +20,9 @@ public static T Run(Func body) { try { result = body(); } catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); } + // Constructing a WPF element spins up a Dispatcher for this thread; without shutdown it + // survives the thread and the test host force-exits over the leftover foreground threads. + finally { Dispatcher.FromThread(Thread.CurrentThread)?.InvokeShutdown(); } }); thread.SetApartmentState(ApartmentState.STA); thread.IsBackground = true; From c744df1361c651f42b8094b6f5575736c7dd0db6 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 16:54:23 +0200 Subject: [PATCH 19/24] Tighten the readme configuration and WPF sections per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ValidateDataAnnotations line: the options class has no annotations, so pointing at [Required] there was talking about something absent. Replace the missing-key essay with the two-block contrast it was describing — a plain Bind that silently leaves non-nullables null, then BindStrongTypes that fails on them, over an options class with a non-nullable string beside the wrapper so "even a plain string" is visible. Remove the "Changed in v2" note from the WPF section: the docs describe v2, and the change belongs in the changelog. Co-Authored-By: Claude Opus 4.8 --- readme.md | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/readme.md b/readme.md index 03979f6f..60bb8ae2 100644 --- a/readme.md +++ b/readme.md @@ -143,22 +143,22 @@ All strong types ship with `System.Text.Json` converters attached via `[JsonConv ### Configuration binding -All strong types ship a `TypeConverter` attached via `[TypeConverter]`, which is what `ConfigurationBinder` uses — so a wrapper works directly on an options class, and its invariant becomes the config validation rule: +All strong types ship a `TypeConverter` attached via `[TypeConverter]`, which is what `ConfigurationBinder` uses — so a wrapper sits directly on an options class and its invariant becomes the config validation rule: ```csharp public sealed class RetryOptions { public Positive MaxRetries { get; set; } - public NonEmptyString? Name { get; set; } + public NonEmptyString ApiName { get; set; } = null!; + public string ApiKey { get; set; } = null!; } builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Retry")) - .ValidateDataAnnotations() // [Required] → catches a missing value - .ValidateOnStart(); // → catches an invalid one, at startup + .ValidateOnStart(); ``` -A bad value fails with the path, the value, and the reason — no `[Range]` and no `IValidateOptions` needed: +A bad value fails at startup with the path, the value, and the reason — no `[Range]` and no `IValidateOptions` needed: ``` InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries' @@ -166,19 +166,17 @@ InvalidOperationException: Failed to convert configuration value '-5' at 'Retry: ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') ``` -`ValidateOnStart()` handles the *invalid* value: binding is lazy, so without it a bad value doesn't fail the deploy — it throws on the first request that reads `IOptions.Value`. - -It does **not** handle the *missing* one. An absent key raises nothing, because binding succeeds and simply doesn't assign — so an unconfigured `NonEmptyString Name` is `null`, which its declaration says is impossible. (A wrapper's invariant constrains every value it can hold; it can't make the binder assign one — and a non-nullable `string` is broken by the same key in the same way. `ConfigurationBinder` predates nullable reference types and never consults them.) `[Required]` covers it, an attribute at a time. Or add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/), which reads the same intent from the nullable annotations you already wrote: +A **missing** key is not a bad value, though: binding just doesn't assign, so a plain `Bind` leaves `ApiName` and `ApiKey` at `null` and nothing complains — default C# binding, which `ValidateOnStart()` doesn't change. [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) does: `BindStrongTypes` fails on every non-nullable reference property left null — the wrapper `ApiName` and the plain `string ApiKey` alike, read from the nullable annotations you already wrote: ```csharp builder.Services.AddOptions() - .BindStrongTypes(builder.Configuration.GetSection("Retry")) // fails on a null non-nullable property + .BindStrongTypes(builder.Configuration.GetSection("Retry")) // 'Retry:ApiName' is null … → OptionsValidationException .ValidateOnStart(); ``` -Value types aren't checked and don't need to be: an unconfigured `Positive` is `1` — a default, and a value the type is happy to hold, not a contradiction. Analyzer `ST0004` flags a plain `Bind` that would leave a wrapper null. +Value types are never required — an unconfigured `Positive` is `1`, a value the type is happy to hold. Analyzer `ST0004` flags a plain `Bind` that would leave a wrapper null and offers the swap. -One more edge, inherited from `ConfigurationBinder`: **`null` and `""` are not interchangeable.** An explicit `"Name": null` nulls even a non-nullable `NonEmptyString`, so omit the key rather than writing `null`; and `""` throws for every wrapper *except* a nullable struct one, where the BCL's `NullableConverter` maps it to `null` first. The full matrix is in the [skill reference](Skill/references/configuration.md). +**`null` and `""` are not interchangeable** (inherited from `ConfigurationBinder`): an explicit `"ApiName": null` nulls even a non-nullable `NonEmptyString`, so omit the key rather than writing `null`; `""` throws for every wrapper *except* a nullable struct one. The full matrix is in the [skill reference](Skill/references/configuration.md). [↑ Back to contents](#contents) @@ -215,8 +213,6 @@ Nothing to install, nothing to call. WPF resolves `string → T` through `TypeDe …where `Name` is a view-model property of type `NonEmptyString`. `ValidatesOnExceptions=True` is the load-bearing piece: it turns the `ArgumentException` a strong type throws on invalid input into a `ValidationError`, driving WPF's standard red-border template. Without it the binding swallows the failure silently. -> **Changed in v2.** This used to require the `Kalicz.StrongTypes.Wpf` package and a `this.UseStrongTypes()` call in `App.OnStartup`. Both are gone — the converters now live on the types. Drop the package reference and the call. - The same `TypeDescriptor` mechanism backs WinForms and designers. MAUI and Avalonia aren't covered yet — see [issue #94](https://github.com/KaliCZ/StrongTypes/issues/94). [↑ Back to contents](#contents) From 2f212a96bb36d1a4c6cb593ee9f2164d00605710 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 16:56:51 +0200 Subject: [PATCH 20/24] Answer "why a separate package" in the Configuration readme The reviewer's question is a reader's question too: this package exists to keep core dependency-free, so a domain model or library using NonEmptyString doesn't inherit the options/configuration stack transitively. State it. Co-Authored-By: Claude Opus 4.8 --- src/StrongTypes.Configuration/readme.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/StrongTypes.Configuration/readme.md b/src/StrongTypes.Configuration/readme.md index d07b901a..83b0c9a4 100644 --- a/src/StrongTypes.Configuration/readme.md +++ b/src/StrongTypes.Configuration/readme.md @@ -76,6 +76,14 @@ distinguishable from a configured default, declare it `Positive?` and check - You already use `[Required]` with `ValidateDataAnnotations()`, which covers the same ground with an attribute per property. +## Why a separate package + +So [`Kalicz.StrongTypes`](https://www.nuget.org/packages/Kalicz.StrongTypes/) stays free of +dependencies. This package pulls in `Microsoft.Extensions.Options.ConfigurationExtensions`; a domain +model or a library that uses `NonEmptyString` should not inherit the options and configuration stack +transitively just to hold a value. Apps that bind configuration already have it, and add one +reference; everyone else keeps a dependency-free core. + ## Nullable reference types Intent is read from the assembly's nullable annotations. An options class in a project with From 3fe692973fbc0cba2371f93279574d7e9c89ef72 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 18:47:18 +0200 Subject: [PATCH 21/24] Drive the culture tests from an explicit case table with negatives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the computed host x binding matrix with concrete InlineData rows — (binding culture, text, expected) — so the data reads as a table and can carry negative cases: expected null means the input is invalid in that culture (not a number, or a value that breaks the Positive invariant), which must raise a validation error and leave the source unchanged. Positive and negative rows assert opposite sides of GetHasError on the same binding, so neither passes vacuously. Host independence still holds: each case runs under every host culture in turn, since WPF resolves ConverterCulture ?? element.Language and never the host. InlineData columns sidestep the object[]-vs-tuple question entirely. Co-Authored-By: Claude Opus 4.8 --- .../CultureBindingTests.cs | 108 +++++++++--------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs index 03fb397d..3decf1bb 100644 --- a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -1,7 +1,6 @@ #nullable enable using System; -using System.Collections.Generic; using System.Globalization; using System.Windows.Controls; using System.Windows.Data; @@ -14,79 +13,80 @@ namespace StrongTypes.Wpf.Tests; /// /// WPF resolves a binding's culture as ConverterCulture ?? element.Language and never consults -/// the host thread culture — so every case runs the same value through a matrix of host thread -/// cultures crossed with binding cultures, proving the host is ignored. +/// the host thread culture. Each case therefore runs under every host culture in turn, asserting the +/// host is ignored while the binding culture governs display, write-back, and round-trip. /// public class CultureBindingTests { - private static readonly string[] Cultures = ["en-US", "de-DE", "cs-CZ", "ja-JP"]; - - public static IEnumerable HostAndBinding() - { - foreach (var host in Cultures) - { - foreach (var binding in Cultures) - { - yield return [host, binding]; - } - } - } + private static readonly string[] HostCultures = ["en-US", "de-DE", "cs-CZ", "ja-JP"]; [Theory] - [MemberData(nameof(HostAndBinding))] - public void Display_usesTheConverterCulture(string hostName, string bindingName) + [InlineData("en-US", 1234.5, "1234.5")] + [InlineData("de-DE", 1234.5, "1234,5")] + [InlineData("cs-CZ", 1234.5, "1234,5")] + [InlineData("ja-JP", 1234.5, "1234.5")] + public void DisplaysAndRoundTripsInTheBindingCulture(string cultureName, double value, string expectedText) { - var binding = CultureInfo.GetCultureInfo(bindingName); - RunOn(hostName, () => + var binding = CultureInfo.GetCultureInfo(cultureName); + var salary = Positive.Create((decimal)value); + RunUnderEveryHost(host => { - var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var vm = new PersonViewModel { Salary = salary }; var textBox = BoundWithConverterCulture(vm, binding); - Assert.Equal(1234.5m.ToString(binding), textBox.Text); - }); - } + Assert.Equal(expectedText, textBox.Text); - [Theory] - [MemberData(nameof(HostAndBinding))] - public void WriteBack_parsesInTheConverterCulture(string hostName, string bindingName) - { - var binding = CultureInfo.GetCultureInfo(bindingName); - RunOn(hostName, () => - { - var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; - var textBox = BoundWithConverterCulture(vm, binding); - - textBox.Text = 9876.5m.ToString(binding); + textBox.Text = textBox.Text; - Assert.Equal(Positive.Create(9876.5m), vm.Salary); + Assert.False(Validation.GetHasError(textBox), $"host {host}: committing the displayed text raised an error"); + Assert.Equal(salary, vm.Salary); }); } - /// Committing the displayed text unedited must not corrupt the value — the bug that shipped when the converter formatted in the host culture but parsed in the binding culture. + /// Write-back parses in the binding culture; a value that isn't valid there (not a number, or one that breaks the invariant) raises a validation error and leaves the source unchanged. Expected null marks those negative cases. [Theory] - [MemberData(nameof(HostAndBinding))] - public void RoundTrip_committingDisplayedTextIsANoOp(string hostName, string bindingName) + [InlineData("en-US", "9876.5", 9876.5)] + [InlineData("de-DE", "9876,5", 9876.5)] + [InlineData("cs-CZ", "9876,5", 9876.5)] + [InlineData("ja-JP", "9876.5", 9876.5)] + [InlineData("en-US", "not-a-number", null)] + [InlineData("de-DE", "not-a-number", null)] + [InlineData("en-US", "-5", null)] + [InlineData("de-DE", "-5", null)] + public void WriteBackParsesInTheBindingCultureOrFails(string cultureName, string text, double? expected) { - var binding = CultureInfo.GetCultureInfo(bindingName); - RunOn(hostName, () => + var binding = CultureInfo.GetCultureInfo(cultureName); + RunUnderEveryHost(host => { - var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var original = Positive.Create(1m); + var vm = new PersonViewModel { Salary = original }; var textBox = BoundWithConverterCulture(vm, binding); - textBox.Text = textBox.Text; + textBox.Text = text; - Assert.Equal(Positive.Create(1234.5m), vm.Salary); - Assert.False(Validation.GetHasError(textBox)); + if (expected is { } number) + { + Assert.False(Validation.GetHasError(textBox), $"host {host}: valid input '{text}' raised an error"); + Assert.Equal(Positive.Create((decimal)number), vm.Salary); + } + else + { + Assert.True(Validation.GetHasError(textBox), $"host {host}: invalid input '{text}' was accepted"); + Assert.Equal(original, vm.Salary); + } }); } /// With no ConverterCulture the binding falls back to the element's Language, still never the host. [Theory] - [MemberData(nameof(HostAndBinding))] - public void NoConverterCulture_usesTheElementLanguageNotTheHost(string hostName, string languageName) + [InlineData("en-US")] + [InlineData("de-DE")] + [InlineData("cs-CZ")] + [InlineData("ja-JP")] + public void NoConverterCulture_usesTheElementLanguageNotTheHost(string languageName) { var language = CultureInfo.GetCultureInfo(languageName); - RunOn(hostName, () => + RunUnderEveryHost(host => { var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; var textBox = new TextBox { Language = XmlLanguage.GetLanguage(languageName) }; @@ -96,11 +96,17 @@ public void NoConverterCulture_usesTheElementLanguageNotTheHost(string hostName, }); } - private static void RunOn(string cultureName, Action body) => StaThread.Run(() => + private static void RunUnderEveryHost(Action body) { - CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); - body(); - }); + foreach (var host in HostCultures) + { + StaThread.Run(() => + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(host); + body(host); + }); + } + } private static TextBox BoundWithConverterCulture(PersonViewModel vm, CultureInfo converterCulture) { From 822b9361ed67ffffd98d277c4c7706273c148a6c Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 18:50:53 +0200 Subject: [PATCH 22/24] Stop implying BindStrongTypes lets an explicit null through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null/"" note sat right after the BindStrongTypes pitch but described plain ConfigurationBinder — so it read as though the check still let `"ApiName": null` null out a non-nullable property. It does not: an explicit null is caught exactly like a missing key (ExplicitNull_Fails proves it). Reword the readme to say so, and the only genuinely different input, "", throws during binding before validation runs. Cross-reference the skill reference's plain-Bind matrix bullet to BindStrongTypes so it can't mislead the same way. Co-Authored-By: Claude Opus 4.8 --- Skill/references/configuration.md | 6 +++--- readme.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md index 250689a4..3b47704d 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -180,9 +180,9 @@ Three things in there surprise people: Don't reach for `""` to mean "unset" — omit the key. - **An explicit `null` nulls even a non-nullable reference property.** Nullability is erased by the time the binder runs, so `"Name": null` leaves a - `NonEmptyString Name` holding `null`, exactly as it would a `string`. The - invariant constrains every value the type can hold; it cannot stop the binder - assigning none. Omit the key rather than writing `null`. + `NonEmptyString Name` holding `null`, exactly as it would a `string` — which is + precisely what `BindStrongTypes` (above) catches, treating it the same as an + absent key. Without that package, omit the key rather than writing `null`. - **An explicit `null` overwrites, an absent key does not.** `"Name": null` clears a property initialised in the options class; leaving the key out keeps it. diff --git a/readme.md b/readme.md index 60bb8ae2..374fc5e7 100644 --- a/readme.md +++ b/readme.md @@ -176,7 +176,7 @@ builder.Services.AddOptions() Value types are never required — an unconfigured `Positive` is `1`, a value the type is happy to hold. Analyzer `ST0004` flags a plain `Bind` that would leave a wrapper null and offers the swap. -**`null` and `""` are not interchangeable** (inherited from `ConfigurationBinder`): an explicit `"ApiName": null` nulls even a non-nullable `NonEmptyString`, so omit the key rather than writing `null`; `""` throws for every wrapper *except* a nullable struct one. The full matrix is in the [skill reference](Skill/references/configuration.md). +`BindStrongTypes` catches an explicit `"ApiName": null` exactly like a missing key — both leave the property null, and both fail. The one input that behaves differently is `""`: the wrapper's converter rejects an empty string during binding itself, so it throws before validation even runs (a nullable struct wrapper is the lone exception, where `""` binds to `null`). The full matrix is in the [skill reference](Skill/references/configuration.md). [↑ Back to contents](#contents) From 6349a491651ccf99197e963e33ff8c37c5b722a5 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 19:24:39 +0200 Subject: [PATCH 23/24] Add the wrong-separator culture cases to the write-back table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier negatives (not-a-number, invariant breach) fail in any culture, so they proved nothing about culture. The real culture case is a separator from the wrong culture, and it does not fail: "9876,5" under en-US binds to 98765, the comma swallowed as digit grouping — verified against the actual converter. That is silent corruption, so pin it: the culture must match the input or you get a wrong-but-valid value. NumberStyles.Number is deliberate (it matches decimal's own parse); a consumer wanting strict parsing controls it at the call site. Co-Authored-By: Claude Opus 4.8 --- src/StrongTypes.Wpf.Tests/CultureBindingTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs index 3decf1bb..23b0959b 100644 --- a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -43,17 +43,24 @@ public void DisplaysAndRoundTripsInTheBindingCulture(string cultureName, double }); } - /// Write-back parses in the binding culture; a value that isn't valid there (not a number, or one that breaks the invariant) raises a validation error and leaves the source unchanged. Expected null marks those negative cases. + /// + /// Write-back parses in the binding culture. A separator from the wrong culture is silently + /// swallowed as digit grouping — "9876,5" under en-US binds to 98765, a valid value + /// and no error — so the culture must match the input. Text that is no number, or that breaks the + /// invariant, raises a validation error and leaves the source unchanged (a null expected). + /// [Theory] [InlineData("en-US", "9876.5", 9876.5)] [InlineData("de-DE", "9876,5", 9876.5)] [InlineData("cs-CZ", "9876,5", 9876.5)] [InlineData("ja-JP", "9876.5", 9876.5)] + [InlineData("en-US", "9876,5", 98765.0)] + [InlineData("de-DE", "9876.5", 98765.0)] [InlineData("en-US", "not-a-number", null)] [InlineData("de-DE", "not-a-number", null)] [InlineData("en-US", "-5", null)] [InlineData("de-DE", "-5", null)] - public void WriteBackParsesInTheBindingCultureOrFails(string cultureName, string text, double? expected) + public void WriteBackParsesInTheBindingCulture(string cultureName, string text, double? expected) { var binding = CultureInfo.GetCultureInfo(cultureName); RunUnderEveryHost(host => From 2b23556d74be6e09b526c0e21cdff642c0521cfa Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 19:37:06 +0200 Subject: [PATCH 24/24] Simplify the configuration binding readme section Lead with the bad-value failure, and condense the plain Bind and BindStrongTypes examples into a single annotated code block, dropping the value-type, explicit-null, and matrix prose. Co-Authored-By: Claude Opus 4.8 --- readme.md | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/readme.md b/readme.md index 374fc5e7..aa016ca3 100644 --- a/readme.md +++ b/readme.md @@ -143,7 +143,13 @@ All strong types ship with `System.Text.Json` converters attached via `[JsonConv ### Configuration binding -All strong types ship a `TypeConverter` attached via `[TypeConverter]`, which is what `ConfigurationBinder` uses — so a wrapper sits directly on an options class and its invariant becomes the config validation rule: +All strong types ship a `TypeConverter` attached via `[TypeConverter]`, which is what `ConfigurationBinder` uses — so a wrapper sits directly on an options class and its invariant becomes the config validation rule. A bad value fails at startup with the path, the value, and the reason: + +``` +InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries' + to type 'StrongTypes.Positive`1[System.Int32]'. + ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') +``` ```csharp public sealed class RetryOptions @@ -153,31 +159,17 @@ public sealed class RetryOptions public string ApiKey { get; set; } = null!; } +// Standard C# behavior: ApiName and ApiKey can still be null. You'd need [Required] to prevent that. builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Retry")) .ValidateOnStart(); -``` - -A bad value fails at startup with the path, the value, and the reason — no `[Range]` and no `IValidateOptions` needed: - -``` -InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries' - to type 'StrongTypes.Positive`1[System.Int32]'. - ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') -``` -A **missing** key is not a bad value, though: binding just doesn't assign, so a plain `Bind` leaves `ApiName` and `ApiKey` at `null` and nothing complains — default C# binding, which `ValidateOnStart()` doesn't change. [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) does: `BindStrongTypes` fails on every non-nullable reference property left null — the wrapper `ApiName` and the plain `string ApiKey` alike, read from the nullable annotations you already wrote: - -```csharp +// ApiName or ApiKey null → OptionsValidationException builder.Services.AddOptions() - .BindStrongTypes(builder.Configuration.GetSection("Retry")) // 'Retry:ApiName' is null … → OptionsValidationException + .BindStrongTypes(builder.Configuration.GetSection("Retry")) // A special method for preventing nulls. .ValidateOnStart(); ``` -Value types are never required — an unconfigured `Positive` is `1`, a value the type is happy to hold. Analyzer `ST0004` flags a plain `Bind` that would leave a wrapper null and offers the swap. - -`BindStrongTypes` catches an explicit `"ApiName": null` exactly like a missing key — both leave the property null, and both fail. The one input that behaves differently is `""`: the wrapper's converter rejects an empty string during binding itself, so it throws before validation even runs (a nullable struct wrapper is the lone exception, where `""` binds to `null`). The full matrix is in the [skill reference](Skill/references/configuration.md). - [↑ Back to contents](#contents) ### EF Core persistence