Saturday, November 29, 2025
C# 14 Extension Blocks: Implementing the | Operator for Fluent Validation

How many times have you written code like this?
public void ProcessOrder(Order order, int quantity, string customerId)
{
if (order == null)
throw new ArgumentNullException(nameof(order));
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(quantity));
if (quantity > 1000)
throw new ArgumentException("Quantity cannot exceed 1000", nameof(quantity));
if (string.IsNullOrWhiteSpace(customerId))
throw new ArgumentException("Customer ID is required", nameof(customerId));
// Finally, the business logic...
}
10 lines of validation before you even get to the real work. It is verbose, repetitive, and it drowns the business logic in an ocean of conditions.
What if I told you that with .NET 10, you can write this instead:
public void ProcessOrder(Order order, int quantity, string customerId)
{
var validOrder = order | (o => o != null);
var validQty = quantity | (q => q > 0) | (q => q <= 1000);
var validId = customerId | (s => !string.IsNullOrWhiteSpace(s));
// The business logic, right away
}
It is clean. It is readable. It is chainable. And it throws the right exceptions automatically.
How is that possible? Thanks to Extension Blocks, a new feature of C# 14 / .NET 10.
▸ Extension Blocks: a revolution for C#
Until now, extension methods in C# had one major limitation: you could add methods, but not operators or properties.
Here is how you used to define a classic extension:
public static class StringExtensions
{
public static bool IsNotEmpty(this string s) => !string.IsNullOrEmpty(s);
}
With Extension Blocks in .NET 10, you can now define operators on any existing type:
public static class MyExtensions
{
extension<T>(T source)
{
public static T operator |(T left, Func<T, bool> predicate)
{
// Our logic here
}
}
}
The
extension<T>(T source) syntax creates a block where source represents the instance you are working on. Inside that block, you can define operators, properties, and methods as if you were modifying the type directly.This is the feature that makes PipeException possible.
▸ PipeExceptionOperations: the heart of the library
Here is the full source of the main class:
public static class PipeExceptionOperations
{
extension<T>(T source)
{
/// <summary>
/// Pipe operator for validation. Returns ValidationResult for deferred exception selection.
/// </summary>
public static ValidationResult<T> operator |(T left, Func<T, bool> predicate)
{
return new ValidationResult<T>(left, predicate, null, null);
}
/// <summary>
/// Pipe operator with custom message.
/// </summary>
public static ValidationResult<T> operator |(T left, (Func<T, bool> predicate, string message) validation)
{
return new ValidationResult<T>(left, validation.predicate, validation.message, null);
}
/// <summary>
/// Validates that the condition is met, throwing ArgumentException if not.
/// </summary>
public T Ensure(
Func<T, bool> predicate,
string? message = null,
[CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null)
{
if (!predicate(source))
{
var errorMessage = message ?? $"Condition not met: {predicateExpression}";
throw new ArgumentException(errorMessage);
}
return source;
}
}
}
Let's break this down:
1. extension<T>(T source)
This is the magic of extension blocks. The block applies to any type
T. The source variable holds the value you are operating on.2. The overloaded | operator
When you write
value | (x => x > 0), this operator is called. It does not validate immediately! It returns a ValidationResult<T> that wraps the value and the predicate.3. The tuple overload
value | (x => x > 0, "Must be positive") goes through the second overload, which also captures the custom message.4. The Ensure method
It uses
[CallerArgumentExpression] to capture the text of the predicate automatically. If you write value.Ensure(x => x > 0 && x < 100), the error message will contain Condition not met: x => x > 0 && x < 100.▸ ValidationResult: the functional pattern
The real power comes from
ValidationResult<T>:public readonly struct ValidationResult<T>
{
private readonly T _value;
private readonly Func<T, bool> _predicate;
private readonly string? _message;
// Enables chaining: result | (x => x < 100)
public static ValidationResult<T> operator |(ValidationResult<T> result, Func<T, bool> predicate)
{
T value = result; // Validate the previous predicate first
return new ValidationResult<T>(value, predicate, null, null);
}
// Implicit conversion to T: this is where the exception is thrown
public static implicit operator T(ValidationResult<T> result)
{
if (!result._predicate(result._value))
{
var errorMessage = result._message ?? $"Condition not met: {result._predicateExpression}";
throw new ArgumentException(errorMessage);
}
return result._value;
}
// Pick the exception type
public T OrThrowNull(string? paramName = null)
{
if (!_predicate(_value))
throw new ArgumentNullException(paramName, _message);
return _value;
}
public T OrThrowInvalidOperation()
{
if (!_predicate(_value))
throw new InvalidOperationException(_message);
return _value;
}
public T OrThrow<TException>(Func<string, TException> exceptionFactory)
where TException : Exception
{
if (!_predicate(_value))
throw exceptionFactory(_message ?? "Validation failed");
return _value;
}
}
The key pattern:
Validation is deferred until you actually need the value
The implicit conversion to
T triggers the validationThe
OrThrow* methods let you pick the exception typeThe
| operator on ValidationResult enables chaining▸ Practical examples
Simple validation
int age = userInput | (x => x >= 0);
// Throws ArgumentException if negative
Chained validations
int score = value
| (x => x >= 0) // Must be positive or zero
| (x => x <= 100) // Maximum 100
| (x => x % 5 == 0); // Multiple of 5
Custom messages
int age = input | (x => x >= 18, "You must be of age to continue");
Picking the exception type
// ArgumentNullException
var user = GetUser() | (u => u != null);
user.OrThrowNull(nameof(user));
// InvalidOperationException
var config = LoadConfig() | (c => c.IsValid);
config.OrThrowInvalidOperation();
// Custom exception
var data = FetchData() | (d => d.Count > 0);
data.OrThrow(msg => new DataNotFoundException(msg));
Ready-made validators
// Numeric
int positive = value | Validate.Positive;
int nonNeg = value | Validate.NonNegative;
int ranged = value | Validate.InRange(1, 100);
// Strings
string name = input | Validate.NotNullOrEmpty;
string text = input | Validate.NotNullOrWhiteSpace;
// Collections
var items = list | Validate.NotEmpty<int>();
var sized = list | Validate.HasMinCount<int>(5);
▸ Specialized extensions included
The library also ships fluent extensions for the common cases:
string validated = input
.EnsureNotNullOrEmpty()
.EnsureMinLength(3)
.EnsureMaxLength(50);
Collections
var items = list
.EnsureNotEmpty()
.EnsureMinCount(1)
.EnsureMaxCount(100);
Nullables
int value = nullableInt.EnsureNotNull("The value is required");
User user = nullableUser.EnsureNotNull(nameof(user), "User not found");
▸ Why PipeException?
Readability: the validation code reads left to right, like a sentence
Chainability: combine several validations in a single expression
Flexibility: choose the exception type after validation
Automatic messages:
CallerArgumentExpression generates meaningful messagesType-safe: everything is checked at compile time
▸ Try it!
Requirements: .NET 10 (extension blocks are a C# 14 feature)
Install:
dotnet add package PipeExceptionThe repo: github.com/phmatray/PipeException
Will extension blocks change the way we write C#? What do you think?
I would love your feedback on this approach to validation. And if you have ideas for improvements, PRs are welcome!
Does this resonate with your team?
Let's talk about how Atypical Consulting can help you move forward.
Contact me