Skip to content

feat: add Validated<T> applicative functor type with error accumulation#103

Open
MCGPPeters wants to merge 2 commits intomainfrom
feat/validated-type
Open

feat: add Validated<T> applicative functor type with error accumulation#103
MCGPPeters wants to merge 2 commits intomainfrom
feat/validated-type

Conversation

@MCGPPeters
Copy link
Copy Markdown
Contributor

📝 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 via Bind), which is correct for dependent validations but wrong for independent ones. Validated<T> fills this gap with applicative composition via Apply.

How

  • Core types (Validated.cs): interface Validated<out T> with Valid<T> and Invalid<T> as readonly record struct variants (zero GC allocation, covariant)
  • ValidationError (Validated/ValidationError.cs): readonly record struct ValidationError(string Field, string Message) — placed in Abies.Validated namespace to avoid collision with Abies.Conduit.Capabilities.ValidationError
  • Combinators (Validated/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 variants
  • Currying (Validated/Curry.cs): First (splits off first arg for Apply chains) and Full (fully curried) helpers for 2–5 arguments
  • Built-in validators (Validated/Validators.cs): Required, NonEmpty, MinLength, MaxLength, Email (source-generated regex), Pattern, Range, Min, Max, That, NotNull, EqualTo, All

Design inspired by:

  • Radix library Validated<T> implementation
  • Haskell Data.Validation, Scala Cats Validated
  • Scott Wlaschin, "Domain Modeling Made Functional"

🔗 Related Issues

Refs #79

✅ Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Code style update (formatting, renaming)
  • ♻️ Refactoring (no functional changes)
  • ⚡ Performance improvement
  • ✅ Test update
  • 🔧 Build/CI configuration change

🧪 Testing

Test Coverage

  • Unit tests added/updated
  • Integration tests added/updated
  • E2E tests added/updated
  • Manual testing performed

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/Traverse
  • CurryTests (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 scenarios

All 287 tests pass (89 new + 198 existing). All 17 analyzer tests pass. Format check clean (IDE0055).

✨ Changes Made

  • Added Abies/Validated.cs — Core Validated<T> interface + Valid<T> / Invalid<T> record structs
  • Added Abies/Validated/ValidationError.cs — Structured field-level error type
  • Added Abies/Validated/Extensions.cs — Full combinator library (~385 lines)
  • Added Abies/Validated/Curry.cs — Currying helpers for multi-arg Apply
  • Added Abies/Validated/Validators.cs — Built-in validation functions with source-generated regex
  • Added Abies.Tests/ValidatedTests.cs — 89 comprehensive unit tests

🔍 Code Review Checklist

  • Code follows the project's style guidelines
  • Self-review of code performed
  • Comments added for complex/non-obvious code
  • Documentation updated (if needed)
  • No new warnings generated
  • Tests added/updated and passing
  • All commits follow Conventional Commits format
  • Branch is up-to-date with main
  • No merge conflicts

🚀 Deployment Notes

None — this is a new library-level type with no runtime dependencies or configuration changes.

📋 Additional Context

Key Design Decisions

Decision Rationale
readonly record struct for variants Zero GC allocation, value semantics, built-in Equals/ToString
interface for sum type Enables covariance (out T), exhaustive switch with _ fallback
ValidationError in Abies.Validated namespace Avoids collision with existing Abies.Conduit.Capabilities.ValidationError
Curry.First (not full curry) for Apply More efficient — each Apply only peels one arg, leaving the rest uncurried
No source generators in phase 1 Keeps complexity low; can add [Validated] attribute codegen in future phase

Applicative vs Monadic — When to Use Which

Result.Bind:      short-circuits on first error  → dependent validations
Validated.Apply:  accumulates ALL errors          → independent validations (form fields)

Example Usage

// Accumulates ALL errors — shows every invalid field at once:
Valid((string name, string email, int age) => new User(name, email, age))
    .Apply(Validate.NonEmpty(name, nameof(name)))
    .Apply(Validate.Email(email, nameof(email)))
    .Apply(Validate.Range(age, 18, 120, nameof(age)))

// Compose multiple validators on a single field:
email.Validate(
    e => Validate.NonEmpty(e, "email"),
    e => Validate.MaxLength(e, 255, "email"),
    e => Validate.Email(e, "email"))

Reviewer Guidelines

Review Focus Areas

  • Correctness: Applicative Apply accumulates errors from both sides (4-way pattern match)
  • Tests: 89 tests covering all combinators, edge cases, and real-world scenarios
  • Security: Email regex has 250ms match timeout to prevent ReDoS
  • Performance: Zero-allocation readonly record struct variants
  • Maintainability: Comprehensive XML doc comments on all public APIs
  • Documentation: Header comments explain design rationale and references

Thank you for contributing to Abies! 🌲

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
Copilot AI review requested due to automatic review settings February 24, 2026 19:10
Comment on lines +262 to +268
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

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

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 WhereValid to:
    • 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.
  • Similarly, rewrite WhereInvalid to:
    • Filter by item is Invalid<T> using .Where(...).
    • Then project to the ValidationError[] using .Select(...).

Concretely:

  • In Abies/Validated/Extensions.cs, add using System.Linq; next to the other using directives.
  • Replace the WhereValid method body with a single return statement using items.Where(...).Select(...).
  • Replace the WhereInvalid method body similarly.

These changes do not alter semantics: both new implementations remain lazy and yield only the desired items.

Suggested changeset 1
Abies/Validated/Extensions.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies/Validated/Extensions.cs b/Abies/Validated/Extensions.cs
--- a/Abies/Validated/Extensions.cs
+++ b/Abies/Validated/Extensions.cs
@@ -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;
+            });
     }
 
     // -------------------------------------------------------------------------
EOF
@@ -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;
});
}

// -------------------------------------------------------------------------
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +274 to +280
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

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

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 returns items.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.

Suggested changeset 1
Abies/Validated/Extensions.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies/Validated/Extensions.cs b/Abies/Validated/Extensions.cs
--- a/Abies/Validated/Extensions.cs
+++ b/Abies/Validated/Extensions.cs
@@ -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);
     }
 
     // -------------------------------------------------------------------------
EOF
@@ -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);
}

// -------------------------------------------------------------------------
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> with Valid<T> / Invalid<T> variants plus ValidationError.
  • 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) => ...), but Valid is a factory method on Abies.Validated.Extensions and isn’t in scope unless the caller adds using static Abies.Validated.Extensions;. Consider qualifying it or showing the required using static so 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"))

Comment on lines +245 to +253
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));
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +174
public static Validated<T> All<T>(params Validated<T>[] validations) =>
validations.Aggregate(
(acc, next) =>
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
/// <example>
/// <code>
/// // Accumulates ALL errors — doesn't stop at the first one:
/// Valid((string name, string email) => new User(name, email))
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// Valid((string name, string email) => new User(name, email))
/// Abies.Validated.Extensions.Valid((string name, string email) => new User(name, email))

Copilot uses AI. Check for mistakes.
/// <code>
/// validated.Match(
/// valid: user => Console.WriteLine($"Welcome {user.Name}"),
/// invalid: errors => errors.ForEach(e => Console.WriteLine($"{e.Field}: {e.Message}"))
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// invalid: errors => errors.ForEach(e => Console.WriteLine($"{e.Field}: {e.Message}"))
/// invalid: errors => System.Array.ForEach(errors, e => Console.WriteLine($"{e.Field}: {e.Message}"))

Copilot uses AI. Check for mistakes.
/// <example>
/// <code>
/// var emails = new[] { "a@b.com", "invalid", "c@d.com" };
/// Validated&lt;IEnumerable&lt;string&gt;&gt; result = emails.Traverse(Validate.Email);
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>>).

Suggested change
/// Validated&lt;IEnumerable&lt;string&gt;&gt; result = emails.Traverse(Validate.Email);
/// Validated&lt;IEnumerable&lt;string&gt;&gt; result =
/// emails.Traverse(email => Validate.Email(email, "email"));

Copilot uses AI. Check for mistakes.
Comment on lines +230 to +239
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)));
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants