diff --git a/FirstClassErrors.RequestBinder.UnitTests/ValueTypePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ValueTypePropertyBindingTests.cs new file mode 100644 index 0000000..d0eecde --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/ValueTypePropertyBindingTests.cs @@ -0,0 +1,192 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +#region Value-type request DTO and value objects + +internal sealed record ValueTypeRequest( + int? Quantity, + bool? Flag, + string? Note, + IReadOnlyList? Quantities, + IReadOnlyList? Notes, + int Count = 0); + +/// A struct value object built from the underlying — the shape a converter binds against. +internal readonly struct PositiveQty { + + public int Value { get; private init; } + + public static Outcome From(int n) { + return n > 0 + ? Outcome.Success(new PositiveQty { Value = n }) + : Outcome.Failure(BookingDomainError.NotAPositiveNumber(n.ToString())); + } + +} + +/// A second struct value object over a different underlying value type (). +internal readonly struct Consent { + + public bool Given { get; private init; } + + public static Outcome From(bool given) { + return Outcome.Success(new Consent { Given = given }); + } + +} + +#endregion + +public sealed class ValueTypePropertyBindingTests { + + private static ValueTypeRequest Request(int? quantity = 5, bool? flag = true, string? note = "hello", + IReadOnlyList? quantities = null, IReadOnlyList? notes = null) { + return new ValueTypeRequest(quantity, flag, note, quantities, notes); + } + + // ── Scalar value-type property ──────────────────────────────────────────────────────────────────────── + + [Fact(DisplayName = "A nullable value-type property binds via a method-group converter over its underlying type.")] + public void ValueTypeRequiredBindsViaMethodGroup() { + var bind = Bind.PropertiesOf(Request(quantity: 5)).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField qty = bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); + + Outcome outcome = bind.New(s => s.Get(qty).Value); + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo(5); + } + + [Fact(DisplayName = "A required value-type property that is absent records REQUEST_ARGUMENT_REQUIRED with its path.")] + public void ValueTypeRequiredMissingRecords() { + var bind = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.IsFailure).IsTrue(); + Error required = outcome.Error!.InnerErrors.Single(); + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantity"); + } + + [Fact(DisplayName = "A present-but-invalid value-type property records REQUEST_ARGUMENT_INVALID wrapping the converter error.")] + public void ValueTypeRequiredInvalidRecords() { + var bind = Bind.PropertiesOf(Request(quantity: -1)).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); + + Outcome outcome = bind.New(_ => "never"); + Error invalid = outcome.Error!.InnerErrors.Single(); + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NOT_A_POSITIVE_NUMBER"); + } + + [Fact(DisplayName = "AsOptionalValue on a value-type property yields the value when present and a real null when absent.")] + public void ValueTypeOptionalValue() { + var present = Bind.PropertiesOf(Request(quantity: 7)).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalValueField some = present.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); + Check.That(present.New(s => s.Get(some)!.Value.Value).GetResultOrThrow()).IsEqualTo(7); + + var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalValueField none = absent.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); + Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue(); + } + + [Fact(DisplayName = "AsOptional with a fallback converts the underlying-typed fallback when the value-type property is absent.")] + public void ValueTypeOptionalWithFallback() { + var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField defaulted = absent.SimpleProperty(r => r.Quantity).AsOptional(PositiveQty.From, 1); + + Check.That(absent.New(s => s.Get(defaulted).Value).GetResultOrThrow()).IsEqualTo(1); + } + + [Fact(DisplayName = "The underlying-type inference is not int-specific: a bool? property binds the same way.")] + public void ValueTypeWorksForBool() { + var bind = Bind.PropertiesOf(Request(flag: true)).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField consent = bind.SimpleProperty(r => r.Flag).AsRequired(Consent.From); + + Check.That(bind.New(s => s.Get(consent).Given).GetResultOrThrow()).IsTrue(); + } + + // ── List of value-type elements ─────────────────────────────────────────────────────────────────────── + + [Fact(DisplayName = "A list of nullable value types binds each element via a method-group converter over the underlying type.")] + public void ValueTypeListRequiredBinds() { + var bind = Bind.PropertiesOf(Request(quantities: [1, 2, 3])).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField> qtys = bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + + Outcome outcome = bind.New(s => s.Get(qtys).Sum(q => q.Value)); + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo(6); + } + + [Fact(DisplayName = "A null element of a value-type list records REQUEST_ARGUMENT_REQUIRED under its indexed path.")] + public void ValueTypeListNullElementRecords() { + var bind = Bind.PropertiesOf(Request(quantities: [1, null, 3])).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + + Error required = bind.New(_ => "never").Error!.InnerErrors.Single(); + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantities[1]"); + } + + [Fact(DisplayName = "An invalid element of a value-type list records REQUEST_ARGUMENT_INVALID under its indexed path.")] + public void ValueTypeListInvalidElementRecords() { + var bind = Bind.PropertiesOf(Request(quantities: [1, -2, 3])).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + + Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single(); + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("Quantities[1]"); + } + + [Fact(DisplayName = "An optional value-type list that is absent yields an empty list and records nothing; a required one records REQUEST_ARGUMENT_REQUIRED.")] + public void ValueTypeListOptionalVsRequiredWhenAbsent() { + var optional = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField> empty = optional.ListOfSimpleProperties(r => r.Quantities).AsOptional(PositiveQty.From); + Check.That(optional.New(s => s.Get(empty).Count).GetResultOrThrow()).IsEqualTo(0); + + var required = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid); + required.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + Check.That(required.New(_ => "never").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + } + + // ── Regressions: reference/string paths keep resolving to the original overloads ────────────────────── + + [Fact(DisplayName = "A string property still resolves to the reference overload — no ambiguity introduced by the value-type overload.")] + public void StringPropertyStillBindsViaReferenceOverload() { + var bind = Bind.PropertiesOf(Request(note: "a@b.c")).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField email = bind.SimpleProperty(r => r.Note).AsRequired(EmailAddress.Parse); + + Check.That(bind.New(s => s.Get(email).Value).GetResultOrThrow()).IsEqualTo("a@b.c"); + } + + [Fact(DisplayName = "A list of strings still resolves to the reference list overload.")] + public void StringListStillBindsViaReferenceOverload() { + var bind = Bind.PropertiesOf(Request(notes: ["a", "b"])).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Notes).AsOptional(Tag.Parse); + + Check.That(bind.New(s => s.Get(tags).Count).GetResultOrThrow()).IsEqualTo(2); + } + + [Fact(DisplayName = "A non-nullable value-type property still throws ArgumentException — the value-type overload does not bypass the guard.")] + public void NonNullableValueTypeStillThrows() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + Check.ThatCode(() => bind.SimpleProperty(r => r.Count)).Throws(); + } + +} diff --git a/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs new file mode 100644 index 0000000..a9297bd --- /dev/null +++ b/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs @@ -0,0 +1,108 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Binds a list request property whose elements are a nullable value type (e.g. int?), each converted +/// by a value-object converter over the underlying non-nullable type. Each failing element is recorded +/// individually, under its indexed path (Quantities[2]), so one bad element never hides the others. +/// +/// +/// The value-type counterpart of : because a +/// Nullable<TArgument> element and a reference-type element need different null handling, the value-type +/// path is a separate converter rather than a reuse of the reference one. It differs only in that a present element +/// is unwrapped (element.Value) before conversion. +/// +/// The type of the request DTO. +/// The underlying (non-nullable) value type of the DTO list elements. +public sealed class ListOfSimpleValuePropertiesConverter where TArgument : struct { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal ListOfSimpleValuePropertiesConverter(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _values = values; + _isMissing = isMissing; + } + + #endregion + + /// + /// Binds a required list: a missing list records REQUEST_ARGUMENT_REQUIRED; each failing element + /// records REQUEST_ARGUMENT_INVALID under its indexed path, and a null element records + /// REQUEST_ARGUMENT_REQUIRED. + /// + /// The type of the element value object. + /// The value-object converter applied to each element's underlying value. + /// The bound field token. + /// Thrown when is null. + public RequiredField> AsRequired(Func> convertElement) where TProperty : notnull { + if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } + + if (_isMissing) { + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + + return new RequiredField>(_binder, default!); + } + + return ConvertElements(convertElement); + } + + /// + /// Binds an optional list: absent yields an empty list (never null) and records nothing; each + /// failing element of a present list still records REQUEST_ARGUMENT_INVALID under its indexed path. + /// + /// The type of the element value object. + /// The value-object converter applied to each element's underlying value. + /// The bound field token. + /// Thrown when is null. + public RequiredField> AsOptional(Func> convertElement) where TProperty : notnull { + if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } + + if (_isMissing) { + IReadOnlyList empty = new List(); + + return new RequiredField>(_binder, empty); + } + + return ConvertElements(convertElement); + } + + private RequiredField> ConvertElements(Func> convertElement) where TProperty : notnull { + List converted = new(); + int index = 0; + + foreach (TArgument? element in _values!) { + string elementPath = $"{_argumentPath}[{index}]"; + index++; + + if (element is null) { + _binder.Record(RequestBindingError.ArgumentRequired(elementPath)); + + continue; + } + + Outcome outcome = convertElement(element.Value); + if (outcome.IsFailure) { + _binder.Record(RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!)); + + continue; + } + + converted.Add(outcome.GetResultOrThrow()); + } + + // The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded — + // i.e. only when every element converted — so `converted` is the complete list exactly when it is observed. + return new RequiredField>(_binder, converted); + } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index b92f386..12285fa 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -95,6 +95,29 @@ public SimplePropertyConverter SimpleProperty(Ex return new SimplePropertyConverter(this, path, (TArgument?)value, value is null); } + /// + /// Selects a scalar value-type property declared nullable (e.g. int?), converted by a value-object + /// converter over the underlying, non-nullable type (Func<int, Outcome<T>>). + /// + /// + /// A nullable value-type property surfaces its underlying type here, not its : a + /// converter written against the underlying type — a value-object factory such as PositiveInt.From — binds + /// as a method group, exactly as a reference-type converter does on the reference overload. A missing value (a + /// null property) is still distinguished from a legitimate default and is handled by the AsRequired + /// / AsOptional variant chosen next. This overload exists because a value type and a reference type cannot + /// share one selector method (the two would differ only by a class / struct constraint, which C# + /// does not treat as an overload); the two selectors carry genuinely different parameter types + /// (TArgument versus Nullable<TArgument>), so they coexist. + /// + /// The underlying (non-nullable) value type of the DTO property. + /// A direct property access on a nullable value-type property (e.g. r => r.MaxNights). + /// The converter stage offering AsRequired / AsOptional and their variants. + public SimplePropertyConverter SimpleProperty(Expression> selector) where TArgument : struct { + (string path, object? value) = ResolveArgument(selector); + + return new SimplePropertyConverter(this, path, value is null ? default : (TArgument)value, value is null); + } + /// /// Selects a complex property, bound by a nested binder. Declare the nested envelope next, with /// . @@ -120,6 +143,26 @@ public ListOfSimplePropertiesConverter ListOfSimpleProperti return new ListOfSimplePropertiesConverter(this, path, (IEnumerable?)value, value is null); } + /// + /// Selects a list property whose elements are a nullable value type (e.g. int?), each converted by + /// a value-object converter over the underlying, non-nullable type (Func<int, Outcome<T>>). + /// + /// + /// The value-type counterpart of the reference/string list overload: each element surfaces its underlying type, + /// so a converter written against it binds as a method group, while a null element is still recorded as a + /// missing argument under its indexed path. It exists for the same reason as the value-type + /// SimpleProperty overload — an IEnumerable<TArgument> selector and an + /// IEnumerable<Nullable<TArgument>> selector are distinct parameter types, so the two coexist. + /// + /// The underlying (non-nullable) value type of the DTO list elements. + /// A direct property access on a list of nullable value types (e.g. r => r.Quantities). + /// The converter stage offering AsRequired / AsOptional. + public ListOfSimpleValuePropertiesConverter ListOfSimpleProperties(Expression?>> selector) where TArgument : struct { + (string path, object? value) = ResolveArgument(selector); + + return new ListOfSimpleValuePropertiesConverter(this, path, (IEnumerable?)value, value is null); + } + /// /// Selects a list property whose elements are bound by a nested binder (one per element). Declare the /// per-element envelope next, with diff --git a/doc/RequestBinder.en.md b/doc/RequestBinder.en.md index 98ba5c9..70a40ef 100644 --- a/doc/RequestBinder.en.md +++ b/doc/RequestBinder.en.md @@ -148,6 +148,28 @@ OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOpti // object, records nothing). It is shown on the guest's optional email under "Lists", below. ``` +**A value-type property binds over its *underlying* type.** When the DTO property +is itself a value type — an `int?`, `bool?`, or `decimal?` that a JSON number or +boolean deserializes into — the converter you pass runs over the **non-nullable +underlying** type: the binder unwraps the `Nullable` for you. A value-object +factory such as `Quantity.From(int)` therefore binds as a method group, exactly as +`EmailAddress.Parse(string)` does for a reference-type property: + +```csharp +// DTO: public sealed record CartRequest(int? Quantity, IReadOnlyList? Lines); + +// Quantity.From is `int -> Outcome`; the binder feeds it the unwrapped int. +RequiredField qty = bind.SimpleProperty(r => r.Quantity).AsRequired(Quantity.From); + +// A list of value types works the same way: each element is converted over its underlying +// int, and a null element records REQUEST_ARGUMENT_REQUIRED under its indexed path (Lines[2]). +RequiredField> lines = bind.ListOfSimpleProperties(r => r.Lines).AsRequired(Quantity.From); +``` + +The property must still be declared nullable (`int?`, not `int`) so the binder can +tell an absent argument from a real value; a non-nullable value-type property +throws instead, as *The bug channel* explains below. + **Optional means "may be absent", never "may be malformed".** A present argument that fails to convert is always an error — even on an optional binding. Only a *missing* optional argument is silent (falling back, yielding `null`, or an empty diff --git a/doc/RequestBinder.fr.md b/doc/RequestBinder.fr.md index 707f079..346d8bf 100644 --- a/doc/RequestBinder.fr.md +++ b/doc/RequestBinder.fr.md @@ -151,6 +151,29 @@ OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOpti // valeur null, n'enregistre rien). Il est montré sur l'email optionnel de l'invité dans « Listes », plus bas. ``` +**Une propriété de type valeur se lie sur son type *sous-jacent*.** Lorsque la +propriété du DTO est elle-même un type valeur — un `int?`, `bool?` ou `decimal?` +dans lequel un nombre ou un booléen JSON est désérialisé — le convertisseur que +vous passez opère sur le type **sous-jacent non-nullable** : le binder déballe le +`Nullable` pour vous. Une fabrique d'objet valeur comme `Quantity.From(int)` se lie +donc comme un groupe de méthodes, exactement comme `EmailAddress.Parse(string)` +pour une propriété de type référence : + +```csharp +// DTO : public sealed record CartRequest(int? Quantity, IReadOnlyList? Lines); + +// Quantity.From est `int -> Outcome` ; le binder lui passe l'int déballé. +RequiredField qty = bind.SimpleProperty(r => r.Quantity).AsRequired(Quantity.From); + +// Une liste de types valeur fonctionne de même : chaque élément est converti sur son int +// sous-jacent, et un élément null enregistre REQUEST_ARGUMENT_REQUIRED sous son chemin indexé (Lines[2]). +RequiredField> lines = bind.ListOfSimpleProperties(r => r.Lines).AsRequired(Quantity.From); +``` + +La propriété doit toujours être déclarée nullable (`int?`, pas `int`) pour que le +binder distingue un argument absent d'une vraie valeur ; une propriété de type +valeur non-nullable lève à la place, comme l'explique *Le canal des bugs* plus bas. + **Optionnel signifie « peut être absent », jamais « peut être malformé ».** Un argument présent qui échoue à se convertir est toujours une erreur — même sur une liaison optionnelle. Seul un argument optionnel *manquant* est silencieux (repli, diff --git a/maintainers/adr/0008-bind-nullable-value-type-properties-through-a-struct-constrained-overload.md b/maintainers/adr/0008-bind-nullable-value-type-properties-through-a-struct-constrained-overload.md new file mode 100644 index 0000000..822df14 --- /dev/null +++ b/maintainers/adr/0008-bind-nullable-value-type-properties-through-a-struct-constrained-overload.md @@ -0,0 +1,132 @@ +# ADR-0008 | Bind nullable value-type properties through a struct-constrained overload + +**Status:** Proposed +**Date:** 2026-07-16 +**Decision Makers:** Reefact + +## Context + +* The request binder selects each DTO property with `SimpleProperty(r => r.X)` or + `ListOfSimpleProperties(r => r.X)`, then a converter binds it — a value-object + factory `Func>`, typically a method group such as + `EmailAddress.Parse`. +* The original selector is generic over `TArgument`, with an unconstrained + `Expression>` parameter. +* When the DTO property is a nullable value type (`int?`), the unconstrained selector + infers `TArgument = Nullable`, because for an unconstrained type parameter the + `?` is a no-op annotation on value types. The converter stage then expects + `Func, Outcome>`, so a method group over the underlying type + (`int -> Outcome`) does not match and the call fails to compile with **CS0411**. +* A non-nullable value-type property is already rejected at bind time (review finding + #4, shipped in #141): such a property must be declared nullable so a missing + argument is distinguishable from a legitimately-sent default. +* C# does not treat two methods that differ only by a `class` versus `struct` + constraint as distinct signatures — a constraint is not part of the signature + (CS0111). +* Under a `where TArgument : struct` constraint, `TArgument?` denotes the constructed + type `Nullable`. That is a different parameter type from the unconstrained + selector's bare `TArgument`, and — being a constructed type — is structurally more + specific for overload resolution. +* A `Nullable` list element can independently be `null`; a converter over + the non-nullable underlying type cannot represent that element, and the reference + list converter dereferences elements without unwrapping a `Nullable`. +* The library is pre-release, unpublished on NuGet with no external consumers. Additive + overloads settled before the v1 freeze cannot shift inference at consumer call sites + that do not yet exist; the same overloads added after consumers write value-type + bindings could change resolution at those sites. + +## Decision + +The request binder binds a nullable value-type DTO property through a dedicated +`where TArgument : struct` selector overload whose selector carries +`Nullable` and whose converter runs over the underlying non-nullable type. + +## Rationale + +* The struct-constrained overload's `Nullable` parameter is a genuinely + different — and structurally more specific — type than the unconstrained overload's + bare `TArgument`, so the two coexist without CS0111 and the value-type overload wins + for a nullable-value-type property, while reference and string properties keep + resolving to the unconstrained overload. The CS0411 failure is removed at the + selector rather than pushed onto the consumer as an adapter lambda. +* Surfacing the underlying non-nullable type (`int`, not `int?`) lets a value-object + factory bind as a method group exactly as it does for a reference property, keeping + one fluent ergonomic across both property kinds: the property stays declared nullable + so absence remains observable, and the underlying type is what the converter + meaningfully operates on. +* A dedicated list converter is required rather than a reuse, because a `Nullable` + element needs its own null handling — a `null` element is a missing argument recorded + under its indexed path — and an unwrap before conversion, neither of which the + reference converter performs. +* Deciding before the v1 freeze settles the API shape while it is still free: the + overloads are additive now, with no call sites to disturb, whereas deferring them + past the freeze would make the same addition a source-breaking change to consumers' + value-type bindings. + +## Alternatives Considered + +### Require consumers to pass an adapter lambda + +Considered because it needs no new API: `AsRequired(v => PositiveInt.From(v))` binds +where the bare method group does not. + +Rejected because it silently degrades the method-group ergonomics for the common +nullable-value-type case, surfaces a CS0411 compile error with no obvious cause, and +duplicates at every call site the unwrap the binder can perform once. + +### A single selector unifying reference and value types + +Considered because one overload is the smallest surface. + +Rejected because C# cannot express it: an unconstrained `TArgument?` cannot both infer +the underlying type for a value-type property and stay a reference for a reference-type +property, and a constraint cannot be varied within one method. + +### Reuse the reference list converter for value-type lists + +Considered because the binding logic is otherwise identical. + +Rejected because a `Nullable` element and a reference element need different null +handling, and the value-type path must unwrap each present element before conversion; +folding both into one converter would obscure that a `null` element is a recorded +missing argument. + +## Consequences + +### Positive + +* A nullable-value-type DTO property (`int?`, `bool?`, ...) and lists of them bind via + a method group over the underlying type, with the same ergonomics as reference + properties. +* The fix is additive and settled before the v1 freeze, so it never becomes a + source-breaking change to a consumer's existing value-type binding. +* Reference and string properties are unaffected: they keep resolving to the original + overload. + +### Negative + +* Two more public selector overloads and one more public converter type to document and + maintain, doubling the selector surface of `SimpleProperty` and + `ListOfSimpleProperties`. +* The mechanism — a `struct` constraint turning `TArgument?` into a more-specific + `Nullable` — is subtle; a maintainer unfamiliar with the overload- + resolution rule may not immediately see why the two selectors coexist. + +### Risks + +* A future third selector shape could interact with the two overloads in ways that + reintroduce ambiguity; mitigated by this ADR and by regression tests that pin + reference and string resolution to the original overload. + +### Follow-up Actions + +* Keep the RequestBinder guide (EN and French) in sync with the value-type binding + ergonomics. + +## References + +* ADR-0007 — name the binder terminals New and Create, the sibling public-API decision + on the same binder. +* Pull requests #126 and #141 — the request binder feature and the non-nullable + value-type guard this decision complements. +* Issue #144 — the CS0411 finding this decision resolves. diff --git a/maintainers/adr/README.md b/maintainers/adr/README.md index d1e4ab7..c528156 100644 --- a/maintainers/adr/README.md +++ b/maintainers/adr/README.md @@ -176,3 +176,4 @@ Optional supporting material: | [ADR-0005](0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md) | Reserve the plain factory name for the Outcome-returning variant | Accepted | | [ADR-0006](0006-supply-arbitrary-test-values-from-a-seedable-source.md) | Supply arbitrary test values from a single seedable source | Accepted | | [ADR-0007](0007-name-the-binder-terminals-new-and-create.md) | Name the binder terminals New and Create | Proposed | +| [ADR-0008](0008-bind-nullable-value-type-properties-through-a-struct-constrained-overload.md) | Bind nullable value-type properties through a struct-constrained overload | Proposed |