Comparison Guards
Learn to use SGuard's comparison guards for range and relational validation.
Overview
SGuard provides five comparison guards that work with any IComparable<T> type:
LessThan:value < otherLessThanOrEqual:value <= otherGreaterThan:value > otherGreaterThanOrEqual:value >= otherBetween: Inclusive range (min <= value <= max)
For strings, Is.LessThan, Is.LessThanOrEqual, Is.GreaterThan, Is.GreaterThanOrEqual, Is.Between and
ThrowIf.Between have overloads that take a StringComparison. The other ThrowIf comparison guards don't; see
String Comparisons.
ThrowIf.* throws when its condition is true: ThrowIf.LessThan(x, 0) throws if x < 0. The built-in exceptions
(LessThanException, GreaterThanException, BetweenException, ...) derive from ArgumentException.
Numeric Comparisons
Basic Usage
ThrowIf.LessThan(age, 0);
ThrowIf.GreaterThan(quantity, maxQuantity);
ThrowIf.LessThanOrEqual(balance, 0m); // balance is a decimal
bool isValid = Is.Between(score, 0, 100);
bool isPositive = Is.GreaterThan(value, 0);
The generic guards require TLeft : IComparable<TRight>. decimal doesn't implement IComparable<int>, so
ThrowIf.LessThan(price, 0) with a decimal price doesn't compile (CS0315). Use a literal of the same type: 0m,
0.0 for double, 0f for float.
Range Validation with Between
The Between guard performs inclusive range checks:
// Checks if value is in [min, max] (inclusive)
bool inRange = Is.Between(value, min, max);
// Throws if value IS within range
ThrowIf.Between(value, min, max);
ThrowIf.Between throws when the value is inside the range. It does not enforce a range: ThrowIf.Between(age, 18, 120) rejects every valid age and lets 5 through. To require min <= value <= max, reject each side instead:
ThrowIf.LessThan(age, 18);
ThrowIf.GreaterThan(age, 120);
// or, with your own exception
if (!Is.Between(age, 18, 120)) throw new ArgumentOutOfRangeException(nameof(age));
Examples:
Is.Between(5, 1, 10); // true (5 is in range)
Is.Between(1, 1, 10); // true (min is allowed)
Is.Between(10, 1, 10); // true (max is allowed)
Is.Between(0, 1, 10); // false (0 is outside range)
If min is greater than max, both Is.Between and ThrowIf.Between throw an ArgumentException ("The minimum must be
less than or equal to the maximum.") instead of silently treating every value as out of range. This check runs when
min and max have the same type, and for the string overloads.
NaN Values
If any operand is a floating-point NaN (double, float or Half), every Is.* comparison returns false and every
ThrowIf.* comparison throws:
Is.GreaterThan(double.NaN, 100.0); // false
Is.Between(double.NaN, 0.0, 100.0); // false
ThrowIf.GreaterThan(double.NaN, 100.0); // throws GreaterThanException
ThrowIf.Between(double.NaN, 0.0, 100.0); // throws BetweenException
A check written as "reject if too large" with Is.* lets NaN through, because the comparison returns false:
if (Is.GreaterThan(amount, max)) { /* reject */ } // NaN is NOT rejected
Use ThrowIf.GreaterThan(amount, max), or test that the value is inside the allowed range with
if (!Is.Between(amount, min, max)) { /* reject */ }, which also rejects NaN.
String Comparisons
Culture-Aware Comparisons
String comparison guards accept StringComparison for proper cultural/ordinal handling:
// Ordinal comparison
bool before = Is.LessThan("apple", "banana", StringComparison.Ordinal);
// Case-insensitive comparison
bool less = Is.LessThan("Apple", "banana", StringComparison.OrdinalIgnoreCase);
// Culture-aware comparison
bool cultureLess = Is.LessThan("straße", "strasse", StringComparison.InvariantCulture);
Throw on Invalid Ordering
ThrowIf.Between is the only ThrowIf guard with a StringComparison overload. For the others, test with Is.*:
// Throws because "zebra" > "apple"
if (Is.GreaterThan("zebra", "apple", StringComparison.Ordinal))
{
throw new InvalidOperationException("Out of order");
}
// Throws if the code IS inside the reserved range
ThrowIf.Between(code, "X00", "X99", StringComparison.Ordinal);
String comparisons are lexicographic. Don't use them for version numbers ("10.0.0" is less than "2.0.0"), prefixes
or paths; see String Comparisons.
DateTime Comparisons
DateTime now = DateTime.UtcNow;
DateTime deadline = GetDeadline();
ThrowIf.GreaterThan(now, deadline,
new InvalidOperationException("Deadline has passed"));
bool isExpired = Is.LessThan(expiryDate, DateTime.UtcNow); // expiry date is in the past
Custom IComparable Types
Any type implementing IComparable<T> works, including System.Version:
Version current = new Version(2, 1);
Version minimum = new Version(2, 0);
ThrowIf.LessThan(current, minimum);
bool isNewer = Is.GreaterThan(current, minimum); // true
Your own types work the same way:
public sealed class ApiLevel : IComparable<ApiLevel>
{
public ApiLevel(int major, int minor)
{
Major = major;
Minor = minor;
}
public int Major { get; }
public int Minor { get; }
public int CompareTo(ApiLevel? other)
{
if (other is null) return 1;
int byMajor = Major.CompareTo(other.Major);
return byMajor != 0 ? byMajor : Minor.CompareTo(other.Minor);
}
}
ThrowIf.LessThan(new ApiLevel(2, 1), new ApiLevel(2, 0)); // doesn't throw
Real-World Examples
Age Validation
public class User
{
public string Username { get; }
public int Age { get; }
public User(string username, int age)
{
ThrowIf.NullOrEmpty(username);
ThrowIf.LessThan(age, 0);
ThrowIf.GreaterThan(age, 130,
new ArgumentOutOfRangeException(nameof(age), "Age seems unrealistic"));
Username = username;
Age = age;
}
}
Quantity Validation
public void AddToCart(Product product, int quantity)
{
ThrowIf.NullOrEmpty(product);
ThrowIf.LessThanOrEqual(quantity, 0,
new ArgumentException("Quantity must be positive", nameof(quantity)));
ThrowIf.GreaterThan(quantity, product.StockQuantity,
new InvalidOperationException("Insufficient stock"));
// Add to cart...
}
Price Range Validation
public void SetPrice(decimal price)
{
const decimal MinPrice = 0.01m;
const decimal MaxPrice = 10000m;
ThrowIf.LessThan(price, MinPrice);
ThrowIf.GreaterThan(price, MaxPrice);
// Or use Between (note: throws if IN range)
// ThrowIf.Between throws when value IS in range
// So use Is.Between for validation:
if (!Is.Between(price, MinPrice, MaxPrice))
{
throw new ArgumentOutOfRangeException(nameof(price));
}
Price = price;
}
Date Range Validation
public void ScheduleMeeting(DateTime start, DateTime end)
{
DateTime now = DateTime.UtcNow;
ThrowIf.LessThan(start, now,
new ArgumentException("Start time must be in the future"));
ThrowIf.LessThanOrEqual(end, start,
new ArgumentException("End time must be after start time"));
// Schedule meeting...
}
Discount Percentage Validation
public class DiscountCalculator
{
public decimal ApplyDiscount(decimal amount, decimal discountPercent)
{
ThrowIf.LessThan(discountPercent, 0m);
ThrowIf.GreaterThan(discountPercent, 100m);
return amount * (1 - discountPercent / 100);
}
}
Combining with Callbacks
ThrowIf.LessThan(
value,
threshold,
SGuardCallbacks.OnFailure(() => logger.LogWarning("Value below threshold")));
bool isValid = Is.Between(
score,
0,
100,
SGuardCallbacks.OnSuccess(() => metrics.Increment("valid.score")));
Best Practices
- Use Between for range checks: It's clearer than combining LessThan and GreaterThan
- Specify StringComparison: Always explicit with string comparisons, and never use string ordering for versions, prefixes or paths
- Prefer
ThrowIf.*or!Is.Betweenfor floating-point input: They reject NaN;if (Is.GreaterThan(...))doesn't - Consider inclusive semantics: Remember Between is inclusive on both ends
- Combine with custom exceptions: Provide meaningful error messages for domain rules
Common Patterns
Exclusive Range Check
Since Between is inclusive, use boolean checks for exclusive ranges:
// Exclusive: min < value < max
if (Is.GreaterThan(value, min) && Is.LessThan(value, max))
{
// value is in exclusive range
}
Clamping Values
// Ensure value stays within bounds
if (!Is.Between(value, min, max))
{
value = Is.LessThan(value, min) ? min : max;
}
Next Steps
- Collection Validation - Any/All guards
- String Comparisons - Deep dive into string handling
- Custom Exceptions - Use domain exceptions