How to Compose Comparers and Equality Rules in C#
Sorted collections, dictionaries, sets and sort calls all accept an IComparer<T> or an IEqualityComparer<T>, and the defaults rarely match what an application needs. Ordering by two members, ignoring case on one of them, or keying a dictionary by the content of a byte array each means writing another small comparer class with its own GetHashCode.
Sphere10 Framework replaces those classes with composition. A comparer is built from member projections and then wrapped by decorators that invert it, chain it or adapt it to another type. This article covers the two builders, the decorator extension methods, and the ready-made comparers for byte arrays and sequences.
1. Build an ordering member by member
ComparerBuilder.For<T>() returns a neutral comparer that treats every pair as equal. StartWith replaces it with a comparer over one member, and each ThenBy or ThenByDescending adds a tie-breaker. Every step accepts an optional comparer for the member's own type.
IComparer<Person> order = ComparerBuilder.For<Person>()
.StartWith(p => p.LastName, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.FirstName)
.ThenByDescending(p => p.Age);
Person is the sample's only model type, a record with four members.
public sealed record Person(int Id, string FirstName, string LastName, int Age);
The comparer is an ordinary IComparer<Person>, so it works with Array.Sort, OrderBy, SortedSet<T> and the Framework's own sorted collections. Each call wraps the previous comparer in a small decorator object.
A ProjectionComparer orders items by one projected key. A CompositeComparer asks its first comparer and consults the second only when the first reports a tie. ThenByDescending is a ThenBy whose key comparer has been inverted.
2. Wrap an existing comparer
The same decorators are available as extension methods on any IComparer<T>, whether or not a builder produced it.
var reversed = people.OrderBy(p => p, order.AsInverted()).Select(p => p.Id).ToArray();
Require(reversed.SequenceEqual(new[] { 2, 4, 1, 3 }), "Inverted ordering differs.");
Require(ReferenceEquals(order.AsInverted().AsInverted(), order), "Inverting twice must unwrap.");
AsInverted reverses an ordering. Inverting an inverted comparer returns the original instance instead of stacking a second wrapper. AsProjection adapts a comparer for one type into a comparer for another, given a function that maps the new type back to the old one.
IComparer<string> ignoreCase = StringComparer.OrdinalIgnoreCase;
IComparer<Person> byLastName = ignoreCase.AsProjection((Person p) => p.LastName);
StringComparer implements both comparer interfaces, so the sample assigns it to an IComparer<string> variable first to select the ordering overload.
3. Build equality the same way
EqualityComparerBuilder.For<T>() starts an equality chain. By selects the first member, and ThenBy adds further members that must also match.
IEqualityComparer<Person> sameName = EqualityComparerBuilder.For<Person>()
.By(p => p.LastName, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.FirstName);
var distinctNames = new HashSet<Person>(people, sameName);
A composed equality comparer reports two items equal only when every rule agrees, and it combines the hash codes of its parts. Hash-based collections therefore work without a hand-written GetHashCode.
The extension methods on IEqualityComparer<T> mirror those for ordering.
-
ThenByadds another rule, by member projection or by a second comparer. -
AsNegationinverts the answer and keeps the hash code. -
AsProjectionadapts the comparer to another type. -
AsPackedhides the item type behindIEqualityComparer<object>for untyped registries.
An ordering can also serve as an equality rule through AsEqualityComparer, which treats a comparison result of zero as equal.
4. Ready-made comparers for common shapes
Arrays and sequences compare by reference by default, which is rarely what a key needs. The Framework includes content-based comparers for them.
var byKey = new Dictionary<byte[], string>(ByteArrayEqualityComparer.Instance) { [new byte[] { 1, 2, 3 }] = "found" };
Require(byKey.TryGetValue(new byte[] { 1, 2, 3 }, out var value) && value == "found", "Byte array keys must match by content.");
var keys = new[] { new byte[] { 2 }, new byte[] { 1, 9 }, new byte[] { 1 } };
Array.Sort(keys, ByteArrayComparer.Instance);
ByteArrayEqualityComparer compares and hashes byte arrays by content, which makes digests and binary identifiers usable as dictionary keys. ByteArrayComparer orders them lexicographically. Sequences of any element type have equivalents that take an item rule.
var sameSequence = new EnumerableEqualityComparer<string>(StringComparer.OrdinalIgnoreCase);
Require(sameSequence.Equals(new[] { "a", "B" }, new[] { "A", "b" }), "Sequences must match element-wise.");
The following comparers cover the remaining common cases.
-
EnumerableComparer<T>orders two sequences lexicographically, with a shorter prefix first. -
EnumerableContainsSameComparer<T>treats sequences as equal when each contains the other's elements in any order. -
TupleComparerandTupleEqualityComparertake one comparer for each tuple element. -
ReferenceEqualityComparercompares by object identity. -
ComparerFactory.Defaultis a registry of comparers by type, which Framework collections and object stores consult.
5. Limits to plan for
-
Hash codes must agree with equality. When you supply a member comparer to
ByorThenBy, itsGetHashCodeis the one used for that member.ComparerToEqualityComparerAdapter<T>hashes with the item's ownGetHashCode(), so use it only when that agrees with the ordering. -
Each step adds a call. A composed comparer invokes a delegate and a nested comparer for each member, so measure it before relying on it in a hot sort.
-
Comparers are immutable. Each extension returns a new wrapper and leaves the original unchanged, so a shared comparer is safe to reuse.
6. Get the package and run the sample
The comparers ship in the Sphere10.Framework NuGet package, in the Sphere10.Framework namespace. The sample targets .NET 10.
dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework
The complete MIT-licensed sample contains every file shown in this article. Extract the download over the generated project and run it.
dotnet run -c Release
Each line is checked by the program before it is printed.
Last name, first name, age descending: 3, 1, 4, 2
Inverted: 2, 4, 1, 3
Distinct names: 3 of 4
Byte array key lookup by content: found
Sequence equality with a case-insensitive item rule: True
The Comparers guide lists every comparer type.
The Sphere10 Framework overview describes the rest of the toolkit.
Author: Herman Schoenfeld