feat: add Validated<T> applicative functor type with error accumulation#103
feat: add Validated<T> applicative functor type with error accumulation#103MCGPPeters wants to merge 2 commits intomainfrom
Conversation
Introduces Validated<T> — an applicative validation type that accumulates ALL errors instead of short-circuiting on the first failure. Essential for form validation where users need to see every field error at once. Core types: - Validated<T> interface (covariant sum type) - Valid<T> / Invalid<T> readonly record structs (zero GC allocation) - ValidationError(Field, Message) structured error type Combinators (Extensions.cs): - Map (functor), Bind (monad), Apply (applicative — error accumulation) - Multi-arg Apply (2–5 fields via Curry.First) - LINQ query syntax (Select, SelectMany) - Match, Traverse, Sequence, Validate (compose validators) - WhereValid / WhereInvalid filtering - Result ↔ Validated conversion - Async Map, Bind, Traverse Built-in validators (Validators.cs): - String: Required, NonEmpty, MinLength, MaxLength, Email, Pattern - Numeric: Range, Min, Max (generic IComparable<T>) - Generic: That, NotNull, EqualTo, All Includes 89 unit tests covering all combinators, currying helpers, validators, and real-world form validation scenarios. Refs #79
| foreach (var item in items) | ||
| { | ||
| if (item is Valid<T>(var value)) | ||
| { | ||
| yield return value; | ||
| } | ||
| } |
Check notice
Code scanning / CodeQL
Missed opportunity to use Where Note
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 1 month ago
In general, to fix “missed opportunity to use Where” you extract the filtering predicate from inside the loop and apply it explicitly via LINQ’s .Where(...), then keep the loop body (or replace it with a projection like .Select(...)) operating only on the filtered sequence. For iterator methods that yield values, this often becomes a simple LINQ pipeline.
For this file, the best fix is:
- Add
using System.Linq;so we can use LINQ extension methods. - Rewrite
WhereValidto:- Filter by
item is Valid<T>using.Where(...). - Then project the underlying value using
.Select(...)with pattern matching. - Return the resulting
IEnumerable<T>directly instead of manually yielding.
- Filter by
- Similarly, rewrite
WhereInvalidto:- Filter by
item is Invalid<T>using.Where(...). - Then project to the
ValidationError[]using.Select(...).
- Filter by
Concretely:
- In
Abies/Validated/Extensions.cs, addusing System.Linq;next to the otherusingdirectives. - Replace the
WhereValidmethod body with a single return statement usingitems.Where(...).Select(...). - Replace the
WhereInvalidmethod body similarly.
These changes do not alter semantics: both new implementations remain lazy and yield only the desired items.
| @@ -10,6 +10,7 @@ | ||
| // ============================================================================= | ||
|
|
||
| using System.Diagnostics; | ||
| using System.Linq; | ||
|
|
||
| namespace Abies.Validated; | ||
|
|
||
| @@ -259,25 +260,25 @@ | ||
| /// <summary>Extracts all valid values from a collection of validated items.</summary> | ||
| public static IEnumerable<T> WhereValid<T>(this IEnumerable<Validated<T>> items) | ||
| { | ||
| foreach (var item in items) | ||
| { | ||
| if (item is Valid<T>(var value)) | ||
| return items | ||
| .Where(item => item is Valid<T>) | ||
| .Select(item => | ||
| { | ||
| yield return value; | ||
| } | ||
| } | ||
| var valid = (Valid<T>)item; | ||
| return valid.Value; | ||
| }); | ||
| } | ||
|
|
||
| /// <summary>Extracts all errors from a collection of validated items.</summary> | ||
| public static IEnumerable<ValidationError[]> WhereInvalid<T>(this IEnumerable<Validated<T>> items) | ||
| { | ||
| foreach (var item in items) | ||
| { | ||
| if (item is Invalid<T>(var errors)) | ||
| return items | ||
| .Where(item => item is Invalid<T>) | ||
| .Select(item => | ||
| { | ||
| yield return errors; | ||
| } | ||
| } | ||
| var invalid = (Invalid<T>)item; | ||
| return invalid.Errors; | ||
| }); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------------- |
| foreach (var item in items) | ||
| { | ||
| if (item is Invalid<T>(var errors)) | ||
| { | ||
| yield return errors; | ||
| } | ||
| } |
Check notice
Code scanning / CodeQL
Missed opportunity to use Where Note
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 1 month ago
In general terms, the fix is to replace the manual filtering within the foreach loop in WhereInvalid with an explicit LINQ-based filter that selects only Invalid<T> instances and then projects their errors arrays. This directly encodes the intent (“where invalid, select errors”) and avoids an implicit filter implemented via an if inside the loop.
Concretely, in Abies/Validated/Extensions.cs, within the WhereInvalid<T> method (lines 272–281), replace the custom foreach/if/yield logic with a LINQ pipeline. Given that Validated<T> has two known shapes, Valid<T> and Invalid<T>, we can use OfType<Invalid<T>>() to filter only invalid results and then Select(v => v.Errors) (or equivalent) to obtain the error arrays. To preserve laziness and minimize changes, we do not need to materialize the sequence; returning the LINQ query as an IEnumerable<ValidationError[]> is sufficient and equivalent to the current iterator method.
The main required change is:
- Replace the body of
WhereInvalid<T>so it returnsitems.OfType<Invalid<T>>().Select(invalid => invalid.Errors);.
No new methods or types are required. If System.Linq is already imported in this file (likely, given earlier use of Aggregate and Enumerable.Empty<T>()), no additional imports are needed. If it were not already present, we would add using System.Linq; at the top, but we will not modify unseen imports per instructions.
| @@ -271,13 +271,9 @@ | ||
| /// <summary>Extracts all errors from a collection of validated items.</summary> | ||
| public static IEnumerable<ValidationError[]> WhereInvalid<T>(this IEnumerable<Validated<T>> items) | ||
| { | ||
| foreach (var item in items) | ||
| { | ||
| if (item is Invalid<T>(var errors)) | ||
| { | ||
| yield return errors; | ||
| } | ||
| } | ||
| return items | ||
| .OfType<Invalid<T>>() | ||
| .Select(invalid => invalid.Errors); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------------- |
There was a problem hiding this comment.
Pull request overview
Adds a new Validated<T> applicative validation type to Abies to enable form-style validation that accumulates multiple errors (as a foundation for upcoming forms/validation features).
Changes:
- Introduces
Validated<T>withValid<T>/Invalid<T>variants plusValidationError. - Adds combinators (Map/Bind/Apply, LINQ support, Traverse/Sequence, conversions, async variants) and currying helpers for multi-arg Apply.
- Adds built-in validators (
Required,NonEmpty,Email,Range, etc.) and a comprehensive unit test suite covering the new API surface.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Abies/Validated.cs | Defines the core Validated<T> sum type and its variants. |
| Abies/Validated/ValidationError.cs | Adds a structured validation error model for field-level display. |
| Abies/Validated/Extensions.cs | Implements applicative/monadic combinators, LINQ support, traversal/sequence, conversions, and async helpers. |
| Abies/Validated/Curry.cs | Provides currying helpers used by multi-arg Apply overloads. |
| Abies/Validated/Validators.cs | Adds a standard validator library (including source-generated email regex) and the All combinator. |
| Abies.Tests/ValidatedTests.cs | Adds unit coverage for core semantics, currying helpers, and validators. |
Comments suppressed due to low confidence (1)
Abies/Validated/Validators.cs:19
- The header comment’s example uses
Valid((string name, string email) => ...), butValidis a factory method onAbies.Validated.Extensionsand isn’t in scope unless the caller addsusing static Abies.Validated.Extensions;. Consider qualifying it or showing the requiredusing staticso the sample compiles as-is.
// Or used directly with Apply for multi-field validation:
//
// Valid((string name, string email) => new User(name, email))
// .Apply(Validate.NonEmpty(name, "name"))
// .Apply(Validate.Email(email, "email"))
| public static Validated<IEnumerable<T>> Sequence<T>( | ||
| this IEnumerable<Validated<T>> validatedItems) => | ||
| validatedItems.Aggregate( | ||
| Valid(Enumerable.Empty<T>()), | ||
| (acc, item) => | ||
| Valid<Func<IEnumerable<T>, T, IEnumerable<T>>>( | ||
| (list, x) => list.Append(x)) | ||
| .Apply(acc) | ||
| .Apply(item)); |
There was a problem hiding this comment.
Sequence uses the same Enumerable.Append-per-item aggregation pattern as Traverse, which can create large chains of deferred iterators and degrade enumeration performance for larger inputs. Consider building an eager collection (e.g., List<T>) and returning it when all items are valid.
| public static Validated<T> All<T>(params Validated<T>[] validations) => | ||
| validations.Aggregate( | ||
| (acc, next) => |
There was a problem hiding this comment.
Validate.All calls validations.Aggregate(...) without a seed value. If a caller passes an empty array, this will throw an InvalidOperationException from LINQ. Consider either (a) explicitly rejecting empty input with an ArgumentException, or (b) defining an identity behavior (if you want All() to be valid by default).
| /// <example> | ||
| /// <code> | ||
| /// // Accumulates ALL errors — doesn't stop at the first one: | ||
| /// Valid((string name, string email) => new User(name, email)) |
There was a problem hiding this comment.
The XML-doc example uses Valid((string name, string email) => ...) as if Valid is in scope, but the factory is Abies.Validated.Extensions.Valid (typically requiring using static Abies.Validated.Extensions;). Consider qualifying the call (or adjusting the example) so it compiles as shown.
| /// Valid((string name, string email) => new User(name, email)) | |
| /// Abies.Validated.Extensions.Valid((string name, string email) => new User(name, email)) |
| /// <code> | ||
| /// validated.Match( | ||
| /// valid: user => Console.WriteLine($"Welcome {user.Name}"), | ||
| /// invalid: errors => errors.ForEach(e => Console.WriteLine($"{e.Field}: {e.Message}")) |
There was a problem hiding this comment.
The XML-doc example uses errors.ForEach(...), but ValidationError[] (and arrays in general) don’t have an instance ForEach method in this codebase. This example won’t compile as written; consider using a foreach loop or Array.ForEach(errors, ...) instead.
| /// invalid: errors => errors.ForEach(e => Console.WriteLine($"{e.Field}: {e.Message}")) | |
| /// invalid: errors => System.Array.ForEach(errors, e => Console.WriteLine($"{e.Field}: {e.Message}")) |
| /// <example> | ||
| /// <code> | ||
| /// var emails = new[] { "a@b.com", "invalid", "c@d.com" }; | ||
| /// Validated<IEnumerable<string>> result = emails.Traverse(Validate.Email); |
There was a problem hiding this comment.
The Traverse XML-doc example calls emails.Traverse(Validate.Email), but Validate.Email requires both (string value, string field). As written, the sample won’t compile; it likely needs a lambda that supplies the field argument (or an overload that matches Func<string, Validated<string>>).
| /// Validated<IEnumerable<string>> result = emails.Traverse(Validate.Email); | |
| /// Validated<IEnumerable<string>> result = | |
| /// emails.Traverse(email => Validate.Email(email, "email")); |
| public static Validated<IEnumerable<TResult>> Traverse<T, TResult>( | ||
| this IEnumerable<T> values, | ||
| Func<T, Validated<TResult>> validator) => | ||
| values.Aggregate( | ||
| Valid(Enumerable.Empty<TResult>()), | ||
| (acc, item) => | ||
| Valid<Func<IEnumerable<TResult>, TResult, IEnumerable<TResult>>>( | ||
| (list, x) => list.Append(x)) | ||
| .Apply(acc) | ||
| .Apply(validator(item))); |
There was a problem hiding this comment.
Traverse builds the output using repeated Enumerable.Append inside an Aggregate. This creates a deep chain of deferred iterators and leads to O(n^2)-ish enumeration overhead as the collection grows. Consider accumulating into a List<TResult> (or similar) and returning it at the end, while still accumulating validation errors applicatively.
- Praefixum 1.2.1-tags-v1-2-0.1 → 2.0.1 (fixes bool default param bug) - Microsoft.CodeAnalysis.CSharp 4.8.0 → 5.0.0 (ships with .NET 10) - Microsoft.CodeAnalysis.Analyzers 3.3.4 → 3.11.0 (required by Roslyn 5.0.0) - Analyzer.Testing 1.1.2 → 1.1.3, Roslyn pins 4.8.0 → 5.0.0
📝 Description
What
Introduces
Validated<T>— an applicative validation type that accumulates ALL errors instead of short-circuiting on the first failure. This is the foundational type for the Forms & Validation framework.Why
When a user submits a form with 5 invalid fields, they should see all 5 errors at once — not one at a time. The existing
Result<T, E>type is monadic (short-circuits viaBind), which is correct for dependent validations but wrong for independent ones.Validated<T>fills this gap with applicative composition viaApply.How
Validated.cs):interface Validated<out T>withValid<T>andInvalid<T>asreadonly record structvariants (zero GC allocation, covariant)Validated/ValidationError.cs):readonly record struct ValidationError(string Field, string Message)— placed inAbies.Validatednamespace to avoid collision withAbies.Conduit.Capabilities.ValidationErrorValidated/Extensions.cs): Map, Bind, Apply (4-way pattern match accumulating errors), multi-arg Apply (2–5 fields via currying), LINQ query syntax, Match, Traverse, Sequence, Validate composition, filtering, Result ↔ Validated conversion, async variantsValidated/Curry.cs):First(splits off first arg for Apply chains) andFull(fully curried) helpers for 2–5 argumentsValidated/Validators.cs): Required, NonEmpty, MinLength, MaxLength, Email (source-generated regex), Pattern, Range, Min, Max, That, NotNull, EqualTo, AllDesign inspired by:
Validated<T>implementationData.Validation, Scala CatsValidated🔗 Related Issues
Refs #79
✅ Type of Change
🧪 Testing
Test Coverage
Testing Details
89 new unit tests across 3 test classes:
ValidatedTests(55 tests): Construction, Map, Bind, Apply (1–5 args with error accumulation), LINQ query syntax, Match, Traverse, Sequence, Validate extension, WhereValid/WhereInvalid filtering, Result ↔ Validated conversion, async Map/Bind/TraverseCurryTests(6 tests):Curry.First(2–5 args),Curry.Full(2–3 args)ValidatorsTests(28 tests): Required, NonEmpty, NotNull (class + struct), MinLength, MaxLength, Email (valid/invalid theory), Pattern, Range/Min/Max, That, EqualTo, All combinator, real-world form validation scenariosAll 287 tests pass (89 new + 198 existing). All 17 analyzer tests pass. Format check clean (IDE0055).
✨ Changes Made
Abies/Validated.cs— CoreValidated<T>interface +Valid<T>/Invalid<T>record structsAbies/Validated/ValidationError.cs— Structured field-level error typeAbies/Validated/Extensions.cs— Full combinator library (~385 lines)Abies/Validated/Curry.cs— Currying helpers for multi-arg ApplyAbies/Validated/Validators.cs— Built-in validation functions with source-generated regexAbies.Tests/ValidatedTests.cs— 89 comprehensive unit tests🔍 Code Review Checklist
🚀 Deployment Notes
None — this is a new library-level type with no runtime dependencies or configuration changes.
📋 Additional Context
Key Design Decisions
readonly record structfor variantsEquals/ToStringinterfacefor sum typeout T), exhaustiveswitchwith_fallbackValidationErrorinAbies.ValidatednamespaceAbies.Conduit.Capabilities.ValidationErrorCurry.First(not full curry) for Apply[Validated]attribute codegen in future phaseApplicative vs Monadic — When to Use Which
Example Usage
Reviewer Guidelines
Review Focus Areas
readonly record structvariantsThank you for contributing to Abies! 🌲