Guard Helpers, Equality Helpers and What Arrived in the BCL from .NET 8 to 11
ArgumentNullException.ThrowIfNull and the ArgumentOutOfRangeException helpers of .NET 8 use CallerArgumentExpression to put the parameter name, the value and the rule into the exception in a single line. We convert Bookstore's Order constructor to these helpers and show custom guard methods, the multi-targeting trap, HashCode.Combine and keys that change, choosing StringComparer and EqualityComparer<T>.Create, how Lazy<T> caches exceptions, and a table of the BCL APIs added from .NET 8 to 11.
Version note: The code in this post was built and run with .NET 10; the key selector overload of EqualityComparer<T>.Create was run with the .NET 11 SDK (11.0.100).
Guess First
An order line in Bookstore, the online bookstore used as the shared example across this site's series, does not accept a quantity of zero. The code below prints three lines. What are the exception's type, its ParamName and its message?
try
{
_ = new OrderItem(Dune, 0, 349.90m);
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.GetType().Name);
Console.WriteLine(ex.ParamName);
Console.WriteLine(ex.Message);
}
ArgumentOutOfRangeException
quantity
quantity ('0') must be a non-negative and non-zero value. (Parameter 'quantity')
Actual value was 0.
The constructor of OrderItem says ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); we never passed the parameter name. Even so, the exception knows the right parameter name, the incoming value and the rule. What makes this possible is the CallerArgumentExpression attribute that arrived with C# 10: the compiler passes the argument expression in the call (quantity) to the helper as a string. This one-line call replaces a hand-written four-line if/throw block.
In the last post of the series we bring three topics together: throw helpers for argument validation and writing your own guard methods, equality and choosing comparers (HashCode.Combine, EqualityComparer<T>.Create, StringComparer), and briefly Lazy<T>. At the end we gather the APIs that arrived in the BCL from .NET 8 to 11 and that we used in this series in a single table.
dotnet run --project samples/Bookstore.Bcl.Samples -- guard-compare --culture en-US
Hand-Written Checks and Throw Helpers
Bookstore's Order class has passed through the hands of several developers over the years. Its constructor looked like this:
// The constructor as it looked before this post: hand-written checks, one message style per developer.
public LegacyOrder(string orderNumber, string customerEmail, IReadOnlyList<OrderItem> items, DateTimeOffset placedAt)
{
if (orderNumber == null)
throw new ArgumentNullException(nameof(orderNumber));
if (orderNumber.Trim().Length == 0)
throw new ArgumentException("Order number is empty");
if (customerEmail == null || !customerEmail.Contains('@'))
throw new ArgumentException("Invalid email", "email");
if (items == null)
throw new ArgumentNullException("items");
if (items.Count == 0)
throw new ArgumentOutOfRangeException(nameof(items), "Order must have items");
if (placedAt == default)
throw new ArgumentException(nameof(placedAt));
// ...
}
At first glance there is no problem, but four bugs are hiding. The second throw passes no parameter name. The third writes the parameter name as "email", while the parameter is called customerEmail; nobody noticed after a refactoring. The fourth uses a string instead of nameof. The last one is a classic mix-up: the single-parameter constructor of ArgumentException takes a message, not a parameter name, so the message becomes "placedAt". The same constructor with throw helpers:
public Order(string orderNumber, string customerEmail, IReadOnlyList<OrderItem> items, DateTimeOffset placedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(orderNumber);
CustomerEmail = Guard.Email(customerEmail);
ArgumentNullException.ThrowIfNull(items);
ArgumentOutOfRangeException.ThrowIfZero(items.Count, nameof(items));
ArgumentOutOfRangeException.ThrowIfEqual(placedAt, default);
OrderNumber = orderNumber;
Items = [.. items];
PlacedAt = placedAt;
}
I passed four bad inputs to both constructors:
blank order number
legacy: ArgumentException ParamName=(null) Order number is empty
helper: ArgumentException ParamName=orderNumber The value cannot be an empty string or composed entirely of whitespace. (Parameter 'orderNumber')
bad e-mail
legacy: ArgumentException ParamName=email Invalid email (Parameter 'email')
helper: ArgumentException ParamName=customerEmail 'ada.example.com' is not an e-mail address. (Parameter 'customerEmail')
no items
legacy: ArgumentOutOfRangeException ParamName=items Order must have items (Parameter 'items')
helper: ArgumentOutOfRangeException ParamName=items items ('0') must be a non-zero value. (Parameter 'items') | Actual value was 0.
default date
legacy: ArgumentException ParamName=(null) placedAt
helper: ArgumentOutOfRangeException ParamName=placedAt placedAt ('1/1/0001 12:00:00 AM +00:00') must not be equal to '1/1/0001 12:00:00 AM +00:00'. (Parameter 'placedAt') | Actual value was 1/1/0001 12:00:00 AM +00:00.
In the helper version every exception carries the right parameter name and the messages follow the same pattern. That means an error filter in ASP.NET Core or a log query can rely on ParamName. It also matters which version each helper arrived in, because they are missing when you compile a library for an older target: ArgumentNullException.ThrowIfNull came with .NET 6, ArgumentException.ThrowIfNullOrEmpty and ObjectDisposedException.ThrowIf with .NET 7, and ThrowIfNullOrWhiteSpace together with generic methods of ArgumentOutOfRangeException such as ThrowIfNegative, ThrowIfZero, ThrowIfGreaterThan and ThrowIfEqual with .NET 8. The methods that take numbers rely on generic math (INumberBase<T>), so they work the same way with int, decimal and double. The comparing ones (ThrowIfGreaterThan, ThrowIfEqual) can be used with any type that implements IComparable<T> and IEquatable<T>, DateTimeOffset included.
Helper Messages and CallerArgumentExpression
CallerArgumentExpression captures the text of the expression in the call, not the parameter name. Most of the time the two are the same; the cases where they differ are surprising:
string? missing = null;
IReadOnlyList<OrderItem> items = [];
Run(() => ArgumentNullException.ThrowIfNull(missing));
Run(() => ArgumentException.ThrowIfNullOrEmpty(""));
Run(() => ArgumentOutOfRangeException.ThrowIfNegative(-5m));
Run(() => ArgumentOutOfRangeException.ThrowIfGreaterThan(501, OrderItem.MaxQuantity));
Run(() => ArgumentOutOfRangeException.ThrowIfZero(items.Count));
Run(() => ArgumentOutOfRangeException.ThrowIfLessThan(PlacedAt, PlacedAt.AddDays(1)));
OrderExport export = new();
export.Dispose();
Run(() => export.Write("BK-1001"));
ArgumentNullException ParamName=missing Value cannot be null. (Parameter 'missing')
ArgumentException ParamName="" The value cannot be an empty string. (Parameter '""')
ArgumentOutOfRangeException ParamName=-5m -5m ('-5') must be a non-negative value. (Parameter '-5m') | Actual value was -5.
ArgumentOutOfRangeException ParamName=501 501 ('501') must be less than or equal to '500'. (Parameter '501') | Actual value was 501.
ArgumentOutOfRangeException ParamName=items.Count items.Count ('0') must be a non-zero value. (Parameter 'items.Count') | Actual value was 0.
ArgumentOutOfRangeException ParamName=PlacedAt PlacedAt ('11/14/2026 9:30:00 AM +03:00') must be greater than or equal to '11/15/2026 9:30:00 AM +03:00'. (Parameter 'PlacedAt') | Actual value was 11/14/2026 9:30:00 AM +03:00.
ObjectDisposedException ParamName=- Cannot access a disposed object. | Object name: 'Bookstore.Bcl.Samples.Guards.GuardDemos+OrderExport'.
With literal values ParamName becomes meaningless ("", -5m, 501); the helpers were designed for parameters, not for computed expressions. The items.Count line is the counterpart of the real code: it is why the Order constructor says ThrowIfZero(items.Count, nameof(items)). Without an explicit parameter name, ParamName would be "items.Count", and the parameter list has no such name. The date format in the messages stands out too: values are written with the culture of the running thread; this output was captured with en-US.
ObjectDisposedException.ThrowIf blocks access to a disposed object in one line and writes the full type name into the message:
public void Write(string orderNumber)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Console.WriteLine(orderNumber);
}
Your Own Guard Methods
For checks the BCL does not have, you can write your own helpers in the same style. The Guard class in the Bookstore library contains two checks:
// Project-specific guards for checks the BCL does not have, in the same style as the built-in helpers.
public static class Guard
{
public static Isbn NotDefault(Isbn value, [CallerArgumentExpression(nameof(value))] string? paramName = null) =>
value == default ? throw new ArgumentException("An ISBN is required.", paramName) : value;
public static string Email(string value, [CallerArgumentExpression(nameof(value))] string? paramName = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value, paramName);
int at = value.IndexOf('@');
if (at <= 0 || at != value.LastIndexOf('@') || at == value.Length - 1)
throw new ArgumentException($"'{value}' is not an e-mail address.", paramName);
return value;
}
}
ArgumentException ParamName=customer.Email 'ada(at)example.com' is not an e-mail address. (Parameter 'customer.Email')
ArgumentException ParamName=isbn An ISBN is required. (Parameter 'isbn')
The first line comes from the call Guard.Email(customer.Email); the expression was captured as it is. Returning the value lets you write the assignment and the check on one line (CustomerEmail = Guard.Email(customerEmail)). In your own guard, do not forget to pass paramName on to the inner helper; if you do, the inner helper writes the name of its own parameter (value). The e-mail check is deliberately simple here; only a verification e-mail tells you whether an address really exists, and checking the format in detail with a regex is a topic for the Regular Expressions series.
Older Target Frameworks and Multi-Targeting
Bookstore's price rules live in a small library shared with an old accounting application that still runs on .NET Framework. The library is compiled for net10.0 and netstandard2.0. Using the helpers unconditionally gives:
PriceRules.cs(7,37): error CS0117: 'ArgumentOutOfRangeException' does not contain a definition for 'ThrowIfNegative'
PriceRules.cs(8,37): error CS0117: 'ArgumentOutOfRangeException' does not contain a definition for 'ThrowIfGreaterThan'
The errors come only from the netstandard2.0 target; the net10.0 build is clean. The fix is to use the helper only on the targets that support it:
public static decimal ApplyDiscount(decimal price, decimal rate)
{
#if NET8_0_OR_GREATER
ArgumentOutOfRangeException.ThrowIfNegative(price);
ArgumentOutOfRangeException.ThrowIfGreaterThan(rate, 1m);
#else
if (price < 0)
throw new ArgumentOutOfRangeException(nameof(price), price, "Price must be non-negative.");
if (rate > 1m)
throw new ArgumentOutOfRangeException(nameof(rate), rate, "Rate must be at most 1.");
#endif
return price * (1 - rate);
}
NET8_0_OR_GREATER is one of the symbols the SDK defines for each target. If such checks multiply, it is cleaner to write your own Guard class and collect the #if there instead of writing #if in every method; some teams also use polyfill packages for this. Multi-targeting itself (TargetFrameworks, conditional package references, platform APIs) is covered in the multi-targeting post of the .NET SDK, CLI and MSBuild series. In the sample repo, the samples/Bookstore.Bcl.MultiTarget project keeps the version with #if and builds for both targets.
Equality: Records, HashCode.Combine and Keys That Change
OrderItem is a record; its equality is generated from its fields. For hand-written equality, there is HashCode.Combine for GetHashCode:
OrderItem first = new(Dune, 2, 349.90m);
OrderItem second = new(Isbn.Parse("9780441172719"), 2, 349.90m);
Console.WriteLine($"record Equals: {first.Equals(second)}, ==: {first == second}, same hash: {first.GetHashCode() == second.GetHashCode()}");
ShelfLocation location = new() { Aisle = "B", Shelf = 4 };
HashSet<ShelfLocation> occupied = [location];
Console.WriteLine($"Contains before change: {occupied.Contains(location)}");
location.Aisle = "C";
Console.WriteLine($"Contains after change : {occupied.Contains(location)}, Count: {occupied.Count}");
Console.WriteLine($"Any(x => x.Equals(...)): {occupied.Any(x => x.Equals(location))}");
public bool Equals(ShelfLocation? other) => other is not null && Aisle == other.Aisle && Shelf == other.Shelf;
public override bool Equals(object? obj) => Equals(obj as ShelfLocation);
public override int GetHashCode() => HashCode.Combine(Aisle, Shelf);
record Equals: True, ==: True, same hash: True
Contains before change: True
Contains after change : False, Count: 1
Any(x => x.Equals(...)): True
The two ISBNs parsed from different strings normalize to the same value, so the records are equal. HashCode.Combine takes up to eight values, produces a well-distributed hash and uses a different seed in every process; do not write the hash value to a file or a database, because it changes on the next run. The real lesson is in the last three lines: after adding the warehouse shelf location to the set, we changed Aisle. The object is still in the set (Count: 1, and Any finds it), but Contains does not, because the hash changed and the set keeps the object in the bucket of the old hash. If the properties that feed the hash can change, do not make that object a dictionary key or a set element; design types that serve as keys to be immutable (a record or a readonly struct).
Choosing Comparers: StringComparer and EqualityComparer<T>.Create
Collections take equality either from the type itself or from a comparer you pass in. The details of comparers are in the "C# Dictionary and HashSet" post of the C# Collections and Generics series; here are the choices you face every day:
string[] volumes = ["Cilt 10", "Cilt 2", "Cilt 1"];
Console.WriteLine($"Ordinal : {string.Join(", ", volumes.Order(StringComparer.Ordinal))}");
StringComparer numeric = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
Console.WriteLine($"NumericOrdering : {string.Join(", ", volumes.Order(numeric))}");
Dictionary<string, decimal> coupons = new(StringComparer.OrdinalIgnoreCase) { ["KITAP10"] = 0.10m };
Console.WriteLine($"OrdinalIgnoreCase finds 'kitap10': {coupons.ContainsKey("kitap10")}");
StringComparer turkish = StringComparer.Create(CultureInfo.GetCultureInfo("tr-TR"), ignoreCase: true);
Console.WriteLine($"tr-TR ignoreCase 'kitap10' == 'KITAP10': {turkish.Equals("kitap10", "KITAP10")}");
EqualityComparer<OrderItem> sameBook = EqualityComparer<OrderItem>.Create(
(x, y) => x?.Isbn == y?.Isbn,
item => item.Isbn.GetHashCode());
OrderItem[] lines = [new(Dune, 1, 349.90m), new(Dune, 3, 329.90m), new(Isbn.Parse("9780140449136"), 1, 189.50m)];
Console.WriteLine($"Distinct books (.NET 8 Create): {lines.Distinct(sameBook).Count()} of {lines.Length} lines");
Ordinal : Cilt 1, Cilt 10, Cilt 2
NumericOrdering : Cilt 1, Cilt 2, Cilt 10
OrdinalIgnoreCase finds 'kitap10': True
tr-TR ignoreCase 'kitap10' == 'KITAP10': False
Distinct books (.NET 8 Create): 2 of 3 lines
Ordinal sorting puts "Cilt 10" before "Cilt 2" for the volumes of a multi-volume series. CompareOptions.NumericOrdering, which arrived with .NET 10, compares the numbers inside the text as numbers; it is the right choice for lists shown on screen. For machine-owned keys such as coupon codes, use OrdinalIgnoreCase: a case-insensitive comparison with the tr-TR culture does not consider kitap10 equal to KITAP10, because in Turkish the uppercase of i is İ. This is the Turkish I problem from the third post of the series, showing up in dictionary keys. EqualityComparer<T>.Create (.NET 8) builds a comparer from two lambdas and removes the need to write a separate class. .NET 11 takes this one step further:
EqualityComparer<Book> byIsbn = EqualityComparer<Book>.Create(book => book?.Isbn);
HashSet<Book> uniqueByIsbn = new(books, byIsbn);
Console.WriteLine($"Distinct by ISBN : {uniqueByIsbn.Count} of {books.Length}");
EqualityComparer<Book> byAuthor = EqualityComparer<Book>.Create(book => book?.Author, StringComparer.OrdinalIgnoreCase);
Console.WriteLine($"Distinct authors : {string.Join(", ", books.Distinct(byAuthor).Select(book => book.Author))}");
Console.WriteLine($"byIsbn.Equals : {byIsbn.Equals(books[0], books[1])}, same hash: {byIsbn.GetHashCode(books[0]) == byIsbn.GetHashCode(books[1])}");
Distinct by ISBN : 4 of 5
Distinct authors : Frank Herbert, Homer, HARPER LEE
byIsbn.Equals : True, same hash: True
The key selector overload defines equality by a key and produces the hash from the same key; the risk of Equals and GetHashCode being inconsistent with each other goes away. The second parameter takes a comparer for the key; here author names were compared without regard to case. The selector's parameter is nullable (Func<T?, TKey?>); in my first attempt I wrote book.Isbn and the compiler gave warning CS8602, hence book?.Isbn.
Lazy<T> and LazyInitializer
To produce an expensive value on first use and only once, there is Lazy<T>. Its default mode is thread-safe, but one behavior surprises most people:
int loads = 0;
Lazy<IReadOnlyDictionary<Isbn, decimal>> catalog = new(() =>
{
Interlocked.Increment(ref loads);
return new Dictionary<Isbn, decimal> { [Dune] = 349.90m };
});
Parallel.For(0, 8, _ => _ = catalog.Value.Count);
Console.WriteLine($"Lazy<T> factory runs: {loads}, IsValueCreated: {catalog.IsValueCreated}");
int attempts = 0;
Lazy<string> flaky = new(() =>
{
attempts++;
if (attempts == 1)
throw new TimeoutException("catalog service timed out");
return "loaded";
});
for (int i = 0; i < 2; i++)
{
try { Console.WriteLine($"flaky.Value: {flaky.Value}"); }
catch (TimeoutException ex) { Console.WriteLine($"attempt {i + 1}: {ex.Message} (factory runs: {attempts})"); }
}
string? settings = null;
int initializations = 0;
for (int i = 0; i < 3; i++)
LazyInitializer.EnsureInitialized(ref settings, () => { initializations++; return "settings"; });
Console.WriteLine($"LazyInitializer runs: {initializations}, value: {settings}");
Lazy<T> factory runs: 1, IsValueCreated: True
attempt 1: catalog service timed out (factory runs: 1)
attempt 2: catalog service timed out (factory runs: 1)
LazyInitializer runs: 1, value: settings
With eight parallel accesses the factory ran once. But when the factory throws, Lazy<T> stores that exception too: on the second attempt the factory did not run at all and the same timeout was thrown again. A transient network error becomes permanent until the application restarts. If the factory can fail, the LazyThreadSafetyMode.PublicationOnly mode does not store the exception (but the factory can then run on several threads at once), or you handle the error inside the factory. LazyInitializer.EnsureInitialized fills a field on first access without creating a separate object; in a race the factory can run more than once and the winning value is kept. For remote data that expires, you need a cache, not laziness.
What Arrived in the BCL from .NET 8 to 11
I gather here the APIs about which we said "this came with .NET 8" or "this exists in .NET 11" throughout the series. The list is not complete; the official "What's new in .NET libraries" page of each release gives the full list. The last column is the post in this series where the API was covered:
| Release | API | What it is for | Post |
|---|---|---|---|
| .NET 8 | CompositeFormat, SearchValues<T> | Parsing a format string once, fast search in a character set | 1 |
| .NET 8 | TimeProvider | A testable clock | 6 |
| .NET 8 | Random.GetItems, Random.Shuffle | Random selection and shuffling | 4 |
| .NET 8 | ThrowIfNullOrWhiteSpace, ThrowIfNegative, ThrowIfGreaterThan and others | Argument validation | 9 |
| .NET 8 | EqualityComparer<T>.Create(equals, getHashCode) | A comparer from lambdas | 9 |
| .NET 9 | Guid.CreateVersion7 | Time-ordered Guid values | 4 |
| .NET 9 | Base64Url, Convert.ToHexStringLower | URL-safe Base64, lowercase hex | 5 |
| .NET 10 | CompareOptions.NumericOrdering | Sorting by the numbers in text | 9 |
| .NET 10 | DateOnly overloads of ISOWeek | Week numbers | 6 |
| .NET 10 | Convert.FromHexString/TryToHexString on UTF-8 bytes, span-based normalization | Allocation-free conversion | 1, 5 |
| .NET 10 | ProcessStartInfo.CreateNewProcessGroup | Starting a child in a separate process group on Windows | - |
| .NET 11 | Process.RunAsync, RunAndCaptureTextAsync, ProcessExitStatus, KillOnParentExit | Running a process in one call | 7 |
| .NET 11 | FORCE_COLOR in the console | Color in redirected output | 8 |
| .NET 11 | Rune overloads in string methods | Searching and splitting with characters such as emoji | 1 |
| .NET 11 | Random.NextInteger<T>, correctly rounded decimal conversion, hex float | Number helpers | 2, 4 |
| .NET 11 | EqualityComparer<T>.Create(keySelector) | Equality by key | 9 |
| .NET 11 | RegexOptions.AnyNewLine, StringStream | Line endings, reading a string as a stream | - |
The two APIs in the last row of the table stayed outside the scope of this series: RegexOptions.AnyNewLine is in the Regular Expressions series, and StringStream and the other in-memory stream adapters are in the .NET 11 post of the I/O in .NET series. .NET 11 is not a new LTS but an STS release; a project that stays on .NET 10 LTS can use these rows only after moving to .NET 11. The upgrade decision and its steps are covered in the "Upgrading C# and .NET" post of the C# Versions series.
In Production
Throw helpers are for catching programmer errors: passing an invalid argument to a method means there is a bug in the code. Validating user input, on the other hand, should be done with a result, not an exception: a 400 response with a per-field error list in a web API, exit code 2 and messages with line numbers in a CLI. Protecting domain rules with guards in the constructor is a good last line of defense, but do not build the message shown to users from the text of these exceptions; the messages are in English, formatted with the culture, and technical.
Equality and hash codes must be correct wherever you use objects as dictionary, set or cache keys. Changing a type's equality affects every place that uses it as a key, so protect such changes with unit tests. In every dictionary with string keys, pass the comparer explicitly; the default is ordinal, but someone reading the code should see it as a decision.
When you use new APIs with #if in a multi-targeting library, make sure both branches are tested: run the tests for every target in CI. An #else branch tested only on net10.0 runs for the first time at the customer who uses the older target.
Common Pitfalls
Using a helper that the older target framework does not have. In a library targeting netstandard2.0 or .NET Framework, the .NET 8 helpers do not compile; use #if NET8_0_OR_GREATER or your own guard class.
Not being able to customize the throw helper's message. The helpers have no message parameter; the message is fixed and in English. If you need a custom message (a domain-specific explanation, an error code), write your own guard method or throw the exception yourself.
Applying a helper to a computed expression. ThrowIfZero(items.Count) writes "items.Count" as the parameter name. Pass the real parameter name with nameof.
Passing a parameter name instead of a message to ArgumentException. The single-parameter constructor takes a message; new ArgumentException(nameof(x)) produces an exception whose message is "x" and whose ParamName is empty.
Making a mutable object a dictionary key. When a property that feeds the hash changes, the object gets lost in the collection. Design keys to be immutable.
Wrapping a factory that can throw in a default Lazy<T>. The first error is stored permanently.
A Small Experiment
★☆☆ Add the rule "the unit price cannot exceed 10,000" to OrderItem with ThrowIfGreaterThan and try it with a line priced 12,500. Look at which culture the number in the message was written with.
★★☆ Add a NotInFuture(DateTimeOffset value, TimeProvider clock) method to the Guard class: it should throw if the order date is later than now. Test it with the FakeTimeProvider from the sixth post.
★★★ Add the net8.0 target to the Bookstore.Bcl.MultiTarget project and build PriceRules for three targets. Then move the #if blocks into a single Guard class and rewrite ApplyDiscount so that it contains no #if at all.
That brings us to the end of the series. In nine posts we looked at the BCL APIs you write every day: string methods and StringComparison, format strings, globalization and ICU, numbers and Guid v7, encodings and URIs, dates, times and time zones, Environment and Process, CLI tools, and finally guards and equality. The common lesson was always the same: defaults often depend on the machine, the culture or the platform, and the problems show up only when the code runs somewhere else; choosing the culture, the encoding, the zone, the comparer and the exit code explicitly removes most of these surprises. If you want to go further, look at the Regular Expressions series for processing text with patterns, the I/O in .NET series for the file and stream side, and the C# Versions series for what is new in the language itself. The final state of the sample code is under the 09 tag in the repo.